diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml
index 9b3fa71a6..6d0d662e6 100644
--- a/.github/workflows/update-config-schema.yml
+++ b/.github/workflows/update-config-schema.yml
@@ -1,4 +1,4 @@
-name: Update FastMCPConfig Schema
+name: Update MCPServerConfig Schema
# This workflow runs on merges to main to automatically update the config schema
# by creating a PR when changes are needed.
@@ -7,8 +7,8 @@ on:
push:
branches: ["main"]
paths:
- - "src/fastmcp/utilities/fastmcp_config/**"
- - "!src/fastmcp/utilities/fastmcp_config/v1/schema.json" # Exclude the local schema file
+ - "src/fastmcp/utilities/mcp_server_config/**"
+ - "!src/fastmcp/utilities/mcp_server_config/v1/schema.json" # Exclude the local schema file
workflow_dispatch:
permissions:
@@ -45,23 +45,23 @@ jobs:
# Generate schema in docs/public for web access
uv run python -c "
- from fastmcp.utilities.fastmcp_config import generate_schema
+ from fastmcp.utilities.mcp_server_config import generate_schema
generate_schema('docs/public/schemas/fastmcp.json/latest.json')
print('✅ Latest schema generated in docs/public')
"
# Also update the v1 schema in docs/public
uv run python -c "
- from fastmcp.utilities.fastmcp_config import generate_schema
+ from fastmcp.utilities.mcp_server_config import generate_schema
generate_schema('docs/public/schemas/fastmcp.json/v1.json')
print('✅ v1 schema generated in docs/public')
"
# Generate schema in the source directory for local development
uv run python -c "
- from fastmcp.utilities.fastmcp_config import generate_schema
- generate_schema('src/fastmcp/utilities/fastmcp_config/v1/schema.json')
- print('✅ Schema generated in utilities/fastmcp_config/v1/')
+ from fastmcp.utilities.mcp_server_config import generate_schema
+ generate_schema('src/fastmcp/utilities/mcp_server_config/v1/schema.json')
+ print('✅ Schema generated in utilities/mcp_server_config/v1/')
"
- name: Create Pull Request
@@ -73,12 +73,14 @@ jobs:
body: |
This PR updates the fastmcp.json schema files to match the current source code.
- The schema is automatically generated from `src/fastmcp/utilities/fastmcp_config/` to ensure consistency.
+ The schema is automatically generated from `src/fastmcp/utilities/mcp_server_config/` to ensure consistency.
**Note:** This PR is fully automated and will update itself with any subsequent changes to the schema, or close automatically if the schema becomes up-to-date through other means. Feel free to leave it open until you're ready to merge.
🤖 Generated by Marvin
branch: marvin/update-config-schema
+ labels: |
+ ignore in release notes
delete-branch: true
author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml
index f275d5234..83ffe7859 100644
--- a/.github/workflows/update-sdk-docs.yml
+++ b/.github/workflows/update-sdk-docs.yml
@@ -62,6 +62,8 @@ jobs:
🤖 Generated by Marvin
branch: marvin/update-sdk-docs
+ labels: |
+ ignore in release notes
delete-branch: true
author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
diff --git a/docs/assets/schemas/fastmcp_config/latest.json b/docs/assets/schemas/mcp_server_config/latest.json
similarity index 100%
rename from docs/assets/schemas/fastmcp_config/latest.json
rename to docs/assets/schemas/mcp_server_config/latest.json
diff --git a/docs/assets/schemas/fastmcp_config/v1.json b/docs/assets/schemas/mcp_server_config/v1.json
similarity index 100%
rename from docs/assets/schemas/fastmcp_config/v1.json
rename to docs/assets/schemas/mcp_server_config/v1.json
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index da758efe6..0b181f8d7 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -4,6 +4,60 @@ icon: "list-check"
rss: true
---
+
+
+**[v2.12.0: Auth to the Races](https://github.com/jlowin/fastmcp/releases/tag/v2.12.0)**
+
+This release introduces major authentication and configuration enhancements that make FastMCP more accessible and powerful for developers working with various identity providers and deployment scenarios.
+
+## OAuth Proxy: Broader Provider Support
+
+The OAuth Proxy bridges the gap for authentication providers that don't support Dynamic Client Registration (DCR), a requirement for standard MCP OAuth flows. This feature enables seamless integration with major platforms that previously required complex workarounds.
+
+**Native integrations now available:**
+- GitHub
+- Google
+- WorkOS
+- Azure
+
+With the OAuth Proxy, you can authenticate users through these providers with minimal configuration, expanding the ecosystem of supported identity platforms and making FastMCP servers more accessible to enterprise environments.
+
+## Declarative JSON Configuration
+
+The new `fastmcp.json` configuration system establishes a single source of truth for server settings, replacing scattered configuration across multiple files and environment variables.
+
+**Configure everything in one place:**
+- Dependencies and requirements
+- Transport settings
+- Server entrypoints
+- Metadata and descriptions
+- Environment variables
+
+This standardization not only simplifies deployment but also enables portable server descriptions that can be shared and reused across projects. The typed source system provides validation and autocompletion, reducing configuration errors.
+
+## Sampling API Fallback
+
+Not all MCP clients support advanced features like the Sampling API for LLM completions. The new fallback mechanism solves this adoption challenge by allowing servers to generate sampling completions server-side when clients lack support.
+
+This approach:
+- Maintains compatibility with all clients
+- Encourages feature adoption without breaking existing integrations
+- Provides a smooth upgrade path as client capabilities evolve
+
+## Breaking Changes
+- The `inspect` command now provides structured output with format options for better integration with tooling
+
+## Additional Enhancements
+- Improved CLI configuration parsing with better error messages
+- Support for multiple `--with-editable` flags for development workflows
+- Comma-separated OAuth scope support for fine-grained permissions
+- Configurable logging middleware for better debugging
+- Support for importing custom route endpoints
+
+**Full Changelog**: [v2.11.3...v2.12.0](https://github.com/jlowin/fastmcp/compare/v2.11.3...v2.12.0)
+
+
+
**[v2.11.3: API-tite for Change](https://github.com/jlowin/fastmcp/releases/tag/v2.11.3)**
diff --git a/docs/deployment/server-configuration.mdx b/docs/deployment/server-configuration.mdx
index 44cbfbd3c..789a8005b 100644
--- a/docs/deployment/server-configuration.mdx
+++ b/docs/deployment/server-configuration.mdx
@@ -44,14 +44,20 @@ This conceptual model helps you understand the purpose of each configuration sec
"$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json",
"source": {
// WHERE: Location of your server code
+ "type": "filesystem", // Optional, defaults to "filesystem"
"path": "server.py",
"entrypoint": "mcp"
},
"environment": {
- // WHAT: Python environment and dependencies
+ // WHAT: Environment setup and dependencies
+ "type": "uv", // Optional, defaults to "uv"
+ "python": ">=3.10",
+ "dependencies": ["pandas", "numpy"]
},
"deployment": {
// HOW: Runtime configuration
+ "transport": "stdio",
+ "log_level": "INFO"
}
}
```
@@ -128,15 +134,21 @@ Future releases will support additional source types:
### 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.
+The environment configuration determines **WHAT** environment setup your server requires. It controls the build-time setup of your Python environment, ensuring your server runs with the exact Python version and dependencies it requires. This section creates 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.
+FastMCP uses an extensible environment system with a base `Environment` class that can be implemented by different environment providers. Currently, FastMCP supports the `UVEnvironment` for Python environment management using `uv`'s powerful dependency resolver.
-
+
- Optional Python environment configuration. When any field is specified, FastMCP automatically creates an isolated environment using `uv`.
+ Optional environment configuration. When specified, FastMCP uses the appropriate environment implementation to set up your server's runtime.
-
+
+ The environment type identifier that determines which implementation to use. Currently supports `"uv"` for Python environments managed by uv. If omitted, defaults to `"uv"`.
+
+
+
+ When `type` is `"uv"` (or omitted), the environment uses uv to manage Python dependencies:
+
Python version constraint. Examples:
- Exact version: `"3.12"`
@@ -175,17 +187,36 @@ These settings leverage standard `uv` arguments for environment creation. When a
"editable": [".", "../shared-lib", "/path/to/another-package"]
```
+
+ **Example:**
+ ```json
+ "environment": {
+ "type": "uv",
+ "python": ">=3.10",
+ "dependencies": ["pandas", "numpy"],
+ "editable": ["."]
+ }
+ ```
+
+ Note: When any UVEnvironment field is specified, FastMCP automatically creates an isolated environment using `uv` before running your server.
When environment configuration is provided, FastMCP:
-1. Creates an isolated Python environment using `uv`
-2. Installs the specified dependencies
-3. Runs your server in this clean environment
+1. Detects the environment type (defaults to `"uv"` if not specified)
+2. Creates an isolated environment using the appropriate provider
+3. Installs the specified dependencies
+4. Runs your server in this clean environment
This build-time setup ensures your server always has the dependencies it needs, without polluting your system Python or conflicting with other projects.
+
+**Future Environment Types**
+
+Similar to source types, future releases may support additional environment types for different runtime requirements, such as Docker containers or language-specific environments beyond Python.
+
+
### 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.
@@ -437,6 +468,7 @@ A configuration optimized for local development:
},
// WHAT dependencies does it need?
"environment": {
+ "type": "uv",
"python": "3.12",
"dependencies": ["fastmcp[dev]"],
"editable": "."
diff --git a/docs/deployment/testing.mdx b/docs/deployment/testing.mdx
index f5db67b58..a7bb63a57 100644
--- a/docs/deployment/testing.mdx
+++ b/docs/deployment/testing.mdx
@@ -133,11 +133,15 @@ async def test_deployed_server():
The FastMCP Client handles authentication transparently, making it easy to test secured servers:
```python
+from fastmcp.client.transports import StreamableHttpTransport
+
async def test_authenticated_server():
# Bearer token authentication
async with Client(
- "https://api.example.com/mcp",
- headers={"Authorization": "Bearer test-token"}
+ StreamableHttpTransport(
+ "https://api.example.com/mcp",
+ headers={"Authorization": "Bearer test-token"}
+ )
) as client:
await client.ping()
tools = await client.list_tools()
diff --git a/docs/docs.json b/docs/docs.json
index 9687185dc..e2bc06e02 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -380,33 +380,41 @@
"python-sdk/fastmcp-utilities-cli",
"python-sdk/fastmcp-utilities-components",
"python-sdk/fastmcp-utilities-exceptions",
- {
- "group": "fastmcp_config",
- "pages": [
- "python-sdk/fastmcp-utilities-fastmcp_config-__init__",
- {
- "group": "v1",
- "pages": [
- "python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__",
- "python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config",
- {
- "group": "sources",
- "pages": [
- "python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__",
- "python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base",
- "python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem"
- ]
- }
- ]
- }
- ]
- },
"python-sdk/fastmcp-utilities-http",
"python-sdk/fastmcp-utilities-inspect",
"python-sdk/fastmcp-utilities-json_schema",
"python-sdk/fastmcp-utilities-json_schema_type",
"python-sdk/fastmcp-utilities-logging",
"python-sdk/fastmcp-utilities-mcp_config",
+ {
+ "group": "mcp_server_config",
+ "pages": [
+ "python-sdk/fastmcp-utilities-mcp_server_config-__init__",
+ {
+ "group": "v1",
+ "pages": [
+ "python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__",
+ {
+ "group": "environments",
+ "pages": [
+ "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__",
+ "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base",
+ "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv"
+ ]
+ },
+ "python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config",
+ {
+ "group": "sources",
+ "pages": [
+ "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__",
+ "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base",
+ "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem"
+ ]
+ }
+ ]
+ }
+ ]
+ },
"python-sdk/fastmcp-utilities-openapi",
"python-sdk/fastmcp-utilities-tests",
"python-sdk/fastmcp-utilities-types"
diff --git a/docs/public/schemas/fastmcp.json/latest.json b/docs/public/schemas/fastmcp.json/latest.json
index c28a50452..fe2e21ce9 100644
--- a/docs/public/schemas/fastmcp.json/latest.json
+++ b/docs/public/schemas/fastmcp.json/latest.json
@@ -162,9 +162,49 @@
"title": "Deployment",
"type": "object"
},
- "Environment": {
+ "FileSystemSource": {
+ "description": "Source for local Python files.",
+ "properties": {
+ "type": {
+ "const": "filesystem",
+ "default": "filesystem",
+ "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"
+ },
+ "UVEnvironment": {
"description": "Configuration for Python environment setup.",
"properties": {
+ "type": {
+ "const": "uv",
+ "default": "uv",
+ "title": "Type",
+ "type": "string"
+ },
"python": {
"anyOf": [
{
@@ -266,42 +306,7 @@
"title": "Editable"
}
},
- "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",
+ "title": "UVEnvironment",
"type": "object"
}
},
@@ -339,7 +344,7 @@
]
},
"environment": {
- "$ref": "#/$defs/Environment",
+ "$ref": "#/$defs/UVEnvironment",
"description": "Python environment setup configuration"
},
"deployment": {
diff --git a/docs/public/schemas/fastmcp.json/v1.json b/docs/public/schemas/fastmcp.json/v1.json
index c28a50452..fe2e21ce9 100644
--- a/docs/public/schemas/fastmcp.json/v1.json
+++ b/docs/public/schemas/fastmcp.json/v1.json
@@ -162,9 +162,49 @@
"title": "Deployment",
"type": "object"
},
- "Environment": {
+ "FileSystemSource": {
+ "description": "Source for local Python files.",
+ "properties": {
+ "type": {
+ "const": "filesystem",
+ "default": "filesystem",
+ "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"
+ },
+ "UVEnvironment": {
"description": "Configuration for Python environment setup.",
"properties": {
+ "type": {
+ "const": "uv",
+ "default": "uv",
+ "title": "Type",
+ "type": "string"
+ },
"python": {
"anyOf": [
{
@@ -266,42 +306,7 @@
"title": "Editable"
}
},
- "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",
+ "title": "UVEnvironment",
"type": "object"
}
},
@@ -339,7 +344,7 @@
]
},
"environment": {
- "$ref": "#/$defs/Environment",
+ "$ref": "#/$defs/UVEnvironment",
"description": "Python environment setup configuration"
},
"deployment": {
diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx
index 60838c769..4dddc51ad 100644
--- a/docs/python-sdk/fastmcp-cli-cli.mdx
+++ b/docs/python-sdk/fastmcp-cli-cli.mdx
@@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts.
## Functions
-### `with_argv`
+### `with_argv`
```python
with_argv(args: list[str] | None)
@@ -27,7 +27,7 @@ Args are provided without the script name, so we preserve sys.argv[0]
and replace the rest.
-### `version`
+### `version`
```python
version()
@@ -37,7 +37,7 @@ version()
Display version information and platform details.
-### `dev`
+### `dev`
```python
dev(server_spec: str | None = None) -> None
@@ -50,7 +50,7 @@ Run an MCP server with the MCP Inspector for development.
- `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json
-### `run`
+### `run`
```python
run(server_spec: str | None = None, *server_args: str) -> None
@@ -74,7 +74,7 @@ fastmcp run server.py -- --config config.json --debug
- `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect
-### `inspect`
+### `inspect`
```python
inspect(server_spec: str | None = None) -> None
@@ -105,7 +105,7 @@ fastmcp inspect # auto-detect fastmcp.json
- `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json
-### `prepare`
+### `prepare`
```python
prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None
diff --git a/docs/python-sdk/fastmcp-cli-install-claude_code.mdx b/docs/python-sdk/fastmcp-cli-install-claude_code.mdx
index fd1d888ff..3889da224 100644
--- a/docs/python-sdk/fastmcp-cli-install-claude_code.mdx
+++ b/docs/python-sdk/fastmcp-cli-install-claude_code.mdx
@@ -57,7 +57,7 @@ Install FastMCP server in Claude Code.
- True if installation was successful, False otherwise
-### `claude_code_command`
+### `claude_code_command`
```python
claude_code_command(server_spec: str) -> None
diff --git a/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx b/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx
index e31704d32..145de8ea8 100644
--- a/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx
+++ b/docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx
@@ -44,7 +44,7 @@ Install FastMCP server in Claude Desktop.
- True if installation was successful, False otherwise
-### `claude_desktop_command`
+### `claude_desktop_command`
```python
claude_desktop_command(server_spec: str) -> None
diff --git a/docs/python-sdk/fastmcp-cli-install-cursor.mdx b/docs/python-sdk/fastmcp-cli-install-cursor.mdx
index 218575f86..6c9ada03c 100644
--- a/docs/python-sdk/fastmcp-cli-install-cursor.mdx
+++ b/docs/python-sdk/fastmcp-cli-install-cursor.mdx
@@ -68,7 +68,7 @@ Install FastMCP server to workspace-specific Cursor configuration.
- True if installation was successful, False otherwise
-### `install_cursor`
+### `install_cursor`
```python
install_cursor(file: Path, server_object: str | None, name: str) -> bool
@@ -93,7 +93,7 @@ Install FastMCP server in Cursor.
- True if installation was successful, False otherwise
-### `cursor_command`
+### `cursor_command`
```python
cursor_command(server_spec: str) -> None
diff --git a/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx b/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx
index 0d218623e..e13343bd7 100644
--- a/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx
+++ b/docs/python-sdk/fastmcp-cli-install-mcp_json.mdx
@@ -35,7 +35,7 @@ Generate MCP configuration JSON for manual installation.
- True if generation was successful, False otherwise
-### `mcp_json_command`
+### `mcp_json_command`
```python
mcp_json_command(server_spec: str) -> None
diff --git a/docs/python-sdk/fastmcp-cli-run.mdx b/docs/python-sdk/fastmcp-cli-run.mdx
index 9f352a1e1..f88d8f431 100644
--- a/docs/python-sdk/fastmcp-cli-run.mdx
+++ b/docs/python-sdk/fastmcp-cli-run.mdx
@@ -10,7 +10,7 @@ FastMCP run command implementation with enhanced type hints.
## Functions
-### `is_url`
+### `is_url`
```python
is_url(path: str) -> bool
@@ -20,7 +20,7 @@ is_url(path: str) -> bool
Check if a string is a URL.
-### `run_with_uv`
+### `run_with_uv`
```python
run_with_uv(server_spec: str, python_version: str | None = None, with_packages: list[str] | None = None, with_requirements: Path | None = None, project: Path | None = None, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, show_banner: bool = True, editable: str | list[str] | None = None) -> None
@@ -29,6 +29,10 @@ run_with_uv(server_spec: str, python_version: str | None = None, with_packages:
Run a MCP server using uv run subprocess.
+This function is called when we need to set up a Python environment with specific
+dependencies before running the server. The config parsing and merging should already
+be done by the caller.
+
**Args:**
- `server_spec`: Python file, object specification (file\:obj), config file, or URL
- `python_version`: Python version to use (e.g. "3.10")
@@ -41,9 +45,10 @@ Run a MCP server using uv run subprocess.
- `path`: Path to bind to when using http transport
- `log_level`: Log level
- `show_banner`: Whether to show the server banner
+- `editable`: Editable package paths
-### `create_client_server`
+### `create_client_server`
```python
create_client_server(url: str) -> Any
@@ -59,7 +64,7 @@ Create a FastMCP server from a client URL.
- A FastMCP server instance
-### `create_mcp_config_server`
+### `create_mcp_config_server`
```python
create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None]
@@ -69,10 +74,10 @@ create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None]
Create a FastMCP server from a MCPConfig.
-### `load_fastmcp_config`
+### `load_mcp_server_config`
```python
-load_fastmcp_config(config_path: Path) -> FastMCPConfig
+load_mcp_server_config(config_path: Path) -> MCPServerConfig
```
@@ -82,10 +87,10 @@ Load a FastMCP configuration from a fastmcp.json file.
- `config_path`: Path to fastmcp.json file
**Returns:**
-- FastMCPConfig object
+- MCPServerConfig object
-### `run_command`
+### `run_command`
```python
run_command(server_spec: str, transport: TransportType | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: LogLevelType | None = None, server_args: list[str] | None = None, show_banner: bool = True, use_direct_import: bool = False, skip_source: bool = False) -> None
@@ -107,7 +112,7 @@ Run a MCP server or connect to a remote one.
- `skip_source`: Whether to skip source preparation step
-### `run_v1_server`
+### `run_v1_server`
```python
run_v1_server(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None
diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx
index 0b261f9b0..1bc7c1704 100644
--- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx
+++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx
@@ -7,13 +7,13 @@ sidebarTitle: oauth
## Functions
-### `default_cache_dir`
+### `default_cache_dir`
```python
default_cache_dir() -> Path
```
-### `check_if_auth_required`
+### `check_if_auth_required`
```python
check_if_auth_required(mcp_url: str, httpx_kwargs: dict[str, Any] | None = None) -> bool
@@ -28,7 +28,13 @@ Check if the MCP endpoint requires authentication by making a test request.
## Classes
-### `FileTokenStorage`
+### `StoredToken`
+
+
+Token storage format with absolute expiry time.
+
+
+### `FileTokenStorage`
File-based token storage implementation for OAuth credentials and tokens.
@@ -39,7 +45,7 @@ Each instance is tied to a specific server URL for proper token isolation.
**Methods:**
-#### `get_base_url`
+#### `get_base_url`
```python
get_base_url(url: str) -> str
@@ -48,7 +54,7 @@ get_base_url(url: str) -> str
Extract the base URL (scheme + host) from a URL.
-#### `get_cache_key`
+#### `get_cache_key`
```python
get_cache_key(self) -> str
@@ -57,7 +63,7 @@ get_cache_key(self) -> str
Generate a safe filesystem key from the server's base URL.
-#### `get_tokens`
+#### `get_tokens`
```python
get_tokens(self) -> OAuthToken | None
@@ -66,7 +72,7 @@ get_tokens(self) -> OAuthToken | None
Load tokens from file storage.
-#### `set_tokens`
+#### `set_tokens`
```python
set_tokens(self, tokens: OAuthToken) -> None
@@ -75,7 +81,7 @@ set_tokens(self, tokens: OAuthToken) -> None
Save tokens to file storage.
-#### `get_client_info`
+#### `get_client_info`
```python
get_client_info(self) -> OAuthClientInformationFull | None
@@ -84,7 +90,7 @@ get_client_info(self) -> OAuthClientInformationFull | None
Load client information from file storage.
-#### `set_client_info`
+#### `set_client_info`
```python
set_client_info(self, client_info: OAuthClientInformationFull) -> None
@@ -93,7 +99,7 @@ set_client_info(self, client_info: OAuthClientInformationFull) -> None
Save client information to file storage.
-#### `clear`
+#### `clear`
```python
clear(self) -> None
@@ -102,7 +108,7 @@ clear(self) -> None
Clear all cached data for this server.
-#### `clear_all`
+#### `clear_all`
```python
clear_all(cls, cache_dir: Path | None = None) -> None
@@ -111,7 +117,7 @@ clear_all(cls, cache_dir: Path | None = None) -> None
Clear all cached data for all servers.
-### `OAuth`
+### `OAuth`
OAuth client provider for MCP servers with browser-based authentication.
@@ -122,7 +128,7 @@ a browser for user authorization and running a local callback server.
**Methods:**
-#### `redirect_handler`
+#### `redirect_handler`
```python
redirect_handler(self, authorization_url: str) -> None
@@ -131,7 +137,7 @@ redirect_handler(self, authorization_url: str) -> None
Open browser for authorization.
-#### `callback_handler`
+#### `callback_handler`
```python
callback_handler(self) -> tuple[str, str | None]
diff --git a/docs/python-sdk/fastmcp-client-client.mdx b/docs/python-sdk/fastmcp-client-client.mdx
index 406d0c329..cc92038eb 100644
--- a/docs/python-sdk/fastmcp-client-client.mdx
+++ b/docs/python-sdk/fastmcp-client-client.mdx
@@ -7,7 +7,7 @@ sidebarTitle: client
## Classes
-### `ClientSessionState`
+### `ClientSessionState`
Holds all session-related state for a Client instance.
@@ -16,7 +16,7 @@ This allows clean separation of configuration (which is copied) from
session state (which should be fresh for each new client instance).
-### `Client`
+### `Client`
MCP client that delegates connection management to a Transport instance.
@@ -79,7 +79,7 @@ async with client:
**Methods:**
-#### `session`
+#### `session`
```python
session(self) -> ClientSession
@@ -88,7 +88,7 @@ session(self) -> ClientSession
Get the current active session. Raises RuntimeError if not connected.
-#### `initialize_result`
+#### `initialize_result`
```python
initialize_result(self) -> mcp.types.InitializeResult
@@ -97,7 +97,7 @@ initialize_result(self) -> mcp.types.InitializeResult
Get the result of the initialization request.
-#### `set_roots`
+#### `set_roots`
```python
set_roots(self, roots: RootsList | RootsHandler) -> None
@@ -106,7 +106,7 @@ set_roots(self, roots: RootsList | RootsHandler) -> None
Set the roots for the client. This does not automatically call `send_roots_list_changed`.
-#### `set_sampling_callback`
+#### `set_sampling_callback`
```python
set_sampling_callback(self, sampling_callback: ClientSamplingHandler) -> None
@@ -115,7 +115,7 @@ set_sampling_callback(self, sampling_callback: ClientSamplingHandler) -> None
Set the sampling callback for the client.
-#### `set_elicitation_callback`
+#### `set_elicitation_callback`
```python
set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None
@@ -124,7 +124,7 @@ set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None
Set the elicitation callback for the client.
-#### `is_connected`
+#### `is_connected`
```python
is_connected(self) -> bool
@@ -133,7 +133,7 @@ is_connected(self) -> bool
Check if the client is currently connected.
-#### `new`
+#### `new`
```python
new(self) -> Client[ClientTransportT]
@@ -149,13 +149,13 @@ share state with the original client.
- A new Client instance with the same configuration but disconnected state.
-#### `close`
+#### `close`
```python
close(self)
```
-#### `ping`
+#### `ping`
```python
ping(self) -> bool
@@ -164,7 +164,7 @@ ping(self) -> bool
Send a ping request.
-#### `cancel`
+#### `cancel`
```python
cancel(self, request_id: str | int, reason: str | None = None) -> None
@@ -173,7 +173,7 @@ cancel(self, request_id: str | int, reason: str | None = None) -> None
Send a cancellation notification for an in-progress request.
-#### `progress`
+#### `progress`
```python
progress(self, progress_token: str | int, progress: float, total: float | None = None, message: str | None = None) -> None
@@ -182,7 +182,7 @@ progress(self, progress_token: str | int, progress: float, total: float | None =
Send a progress notification.
-#### `set_logging_level`
+#### `set_logging_level`
```python
set_logging_level(self, level: mcp.types.LoggingLevel) -> None
@@ -191,7 +191,7 @@ set_logging_level(self, level: mcp.types.LoggingLevel) -> None
Send a logging/setLevel request.
-#### `send_roots_list_changed`
+#### `send_roots_list_changed`
```python
send_roots_list_changed(self) -> None
@@ -200,7 +200,7 @@ send_roots_list_changed(self) -> None
Send a roots/list_changed notification.
-#### `list_resources_mcp`
+#### `list_resources_mcp`
```python
list_resources_mcp(self) -> mcp.types.ListResourcesResult
@@ -216,7 +216,7 @@ containing the list of resources and any additional metadata.
- `RuntimeError`: If called while the client is not connected.
-#### `list_resources`
+#### `list_resources`
```python
list_resources(self) -> list[mcp.types.Resource]
@@ -231,7 +231,7 @@ Retrieve a list of resources available on the server.
- `RuntimeError`: If called while the client is not connected.
-#### `list_resource_templates_mcp`
+#### `list_resource_templates_mcp`
```python
list_resource_templates_mcp(self) -> mcp.types.ListResourceTemplatesResult
@@ -247,7 +247,7 @@ containing the list of resource templates and any additional metadata.
- `RuntimeError`: If called while the client is not connected.
-#### `list_resource_templates`
+#### `list_resource_templates`
```python
list_resource_templates(self) -> list[mcp.types.ResourceTemplate]
@@ -262,7 +262,7 @@ Retrieve a list of resource templates available on the server.
- `RuntimeError`: If called while the client is not connected.
-#### `read_resource_mcp`
+#### `read_resource_mcp`
```python
read_resource_mcp(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult
@@ -281,7 +281,7 @@ containing the resource contents and any additional metadata.
- `RuntimeError`: If called while the client is not connected.
-#### `read_resource`
+#### `read_resource`
```python
read_resource(self, uri: AnyUrl | str) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]
@@ -300,7 +300,7 @@ objects, typically containing either text or binary data.
- `RuntimeError`: If called while the client is not connected.
-#### `list_prompts_mcp`
+#### `list_prompts_mcp`
```python
list_prompts_mcp(self) -> mcp.types.ListPromptsResult
@@ -316,7 +316,7 @@ containing the list of prompts and any additional metadata.
- `RuntimeError`: If called while the client is not connected.
-#### `list_prompts`
+#### `list_prompts`
```python
list_prompts(self) -> list[mcp.types.Prompt]
@@ -331,7 +331,7 @@ Retrieve a list of prompts available on the server.
- `RuntimeError`: If called while the client is not connected.
-#### `get_prompt_mcp`
+#### `get_prompt_mcp`
```python
get_prompt_mcp(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult
@@ -351,7 +351,7 @@ containing the prompt messages and any additional metadata.
- `RuntimeError`: If called while the client is not connected.
-#### `get_prompt`
+#### `get_prompt`
```python
get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult
@@ -371,7 +371,7 @@ containing the prompt messages and any additional metadata.
- `RuntimeError`: If called while the client is not connected.
-#### `complete_mcp`
+#### `complete_mcp`
```python
complete_mcp(self, ref: mcp.types.ResourceReference | mcp.types.PromptReference, argument: dict[str, str]) -> mcp.types.CompleteResult
@@ -391,7 +391,7 @@ containing the completion and any additional metadata.
- `RuntimeError`: If called while the client is not connected.
-#### `complete`
+#### `complete`
```python
complete(self, ref: mcp.types.ResourceReference | mcp.types.PromptReference, argument: dict[str, str]) -> mcp.types.Completion
@@ -410,7 +410,7 @@ Send a completion request to the server.
- `RuntimeError`: If called while the client is not connected.
-#### `list_tools_mcp`
+#### `list_tools_mcp`
```python
list_tools_mcp(self) -> mcp.types.ListToolsResult
@@ -426,7 +426,7 @@ containing the list of tools and any additional metadata.
- `RuntimeError`: If called while the client is not connected.
-#### `list_tools`
+#### `list_tools`
```python
list_tools(self) -> list[mcp.types.Tool]
@@ -441,7 +441,7 @@ Retrieve a list of tools available on the server.
- `RuntimeError`: If called while the client is not connected.
-#### `call_tool_mcp`
+#### `call_tool_mcp`
```python
call_tool_mcp(self, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None) -> mcp.types.CallToolResult
@@ -466,7 +466,7 @@ containing the tool result and any additional metadata.
- `RuntimeError`: If called while the client is not connected.
-#### `call_tool`
+#### `call_tool`
```python
call_tool(self, name: str, arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, raise_on_error: bool = True) -> CallToolResult
@@ -496,10 +496,10 @@ raw result object.
- `RuntimeError`: If called while the client is not connected.
-#### `generate_name`
+#### `generate_name`
```python
generate_name(cls, name: str | None = None) -> str
```
-### `CallToolResult`
+### `CallToolResult`
diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx
index b592bdc8c..6d261e0a4 100644
--- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx
@@ -182,7 +182,7 @@ Handles provider-specific requirements:
**Methods:**
-#### `get_client`
+#### `get_client`
```python
get_client(self, client_id: str) -> OAuthClientInformationFull | None
@@ -199,7 +199,7 @@ handles the case where a client with cached tokens reconnects
on a different port.
-#### `register_client`
+#### `register_client`
```python
register_client(self, client_info: OAuthClientInformationFull) -> None
@@ -226,7 +226,7 @@ The flow:
4. When client reconnects with a different port, ProxyDCRClient accepts it
-#### `authorize`
+#### `authorize`
```python
authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str
@@ -240,7 +240,7 @@ This implements the DCR-compliant proxy pattern:
3. Redirect to IdP with our fixed callback URL
-#### `load_authorization_code`
+#### `load_authorization_code`
```python
load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None
@@ -252,7 +252,7 @@ Look up our client code and return authorization code object
with PKCE challenge for validation.
-#### `exchange_authorization_code`
+#### `exchange_authorization_code`
```python
exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken
@@ -264,7 +264,7 @@ For the DCR-compliant proxy flow, we return the IdP tokens that were obtained
during the IdP callback exchange. PKCE validation is handled by the MCP framework.
-#### `load_refresh_token`
+#### `load_refresh_token`
```python
load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None
@@ -273,7 +273,7 @@ load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str)
Load refresh token from local storage.
-#### `exchange_refresh_token`
+#### `exchange_refresh_token`
```python
exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken
@@ -282,7 +282,7 @@ exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token:
Exchange refresh token for new access token using authlib.
-#### `load_access_token`
+#### `load_access_token`
```python
load_access_token(self, token: str) -> AccessToken | None
@@ -294,7 +294,7 @@ Delegates to the JWT verifier which handles signature validation,
expiration checking, and claims validation using the upstream JWKS.
-#### `revoke_token`
+#### `revoke_token`
```python
revoke_token(self, token: AccessToken | RefreshToken) -> None
@@ -306,7 +306,7 @@ Removes tokens from local storage and attempts to revoke them with
the upstream server if a revocation endpoint is configured.
-#### `get_routes`
+#### `get_routes`
```python
get_routes(self) -> list[Route]
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx
index ed66062f9..343128eba 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx
@@ -20,7 +20,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows.
Settings for Azure OAuth provider.
-### `AzureTokenVerifier`
+### `AzureTokenVerifier`
Token verifier for Azure OAuth tokens.
@@ -31,7 +31,7 @@ to get user information and validate the token.
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -40,7 +40,7 @@ verify_token(self, token: str) -> AccessToken | None
Verify Azure OAuth token by calling Microsoft Graph API.
-### `AzureProvider`
+### `AzureProvider`
Azure (Microsoft Entra) OAuth provider for FastMCP.
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx
index e6f23ab15..91d86fa87 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx
@@ -35,7 +35,7 @@ Example:
Settings for GitHub OAuth provider.
-### `GitHubTokenVerifier`
+### `GitHubTokenVerifier`
Token verifier for GitHub OAuth tokens.
@@ -46,7 +46,7 @@ by calling GitHub's API to check if they're valid and get user info.
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None
Verify GitHub OAuth token by calling GitHub API.
-### `GitHubProvider`
+### `GitHubProvider`
Complete GitHub OAuth provider for FastMCP.
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx
index 4f4d4688b..2eaf874cc 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx
@@ -35,7 +35,7 @@ Example:
Settings for Google OAuth provider.
-### `GoogleTokenVerifier`
+### `GoogleTokenVerifier`
Token verifier for Google OAuth tokens.
@@ -46,7 +46,7 @@ by calling Google's tokeninfo API to check if they're valid and get user info.
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None
Verify Google OAuth token by calling Google's tokeninfo API.
-### `GoogleProvider`
+### `GoogleProvider`
Complete Google OAuth provider for FastMCP.
diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx
index 60781bf24..fdacdf859 100644
--- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx
@@ -24,7 +24,7 @@ Choose based on your WorkOS setup and authentication requirements.
Settings for WorkOS OAuth provider.
-### `WorkOSTokenVerifier`
+### `WorkOSTokenVerifier`
Token verifier for WorkOS OAuth tokens.
@@ -35,7 +35,7 @@ the /oauth2/userinfo endpoint to check validity and get user info.
**Methods:**
-#### `verify_token`
+#### `verify_token`
```python
verify_token(self, token: str) -> AccessToken | None
@@ -44,7 +44,7 @@ verify_token(self, token: str) -> AccessToken | None
Verify WorkOS OAuth token by calling userinfo endpoint.
-### `WorkOSProvider`
+### `WorkOSProvider`
Complete WorkOS OAuth provider for FastMCP.
@@ -65,9 +65,9 @@ Setup Requirements:
4. Note your Client ID and Client Secret
-### `AuthKitProviderSettings`
+### `AuthKitProviderSettings`
-### `AuthKitProvider`
+### `AuthKitProvider`
AuthKit metadata provider for DCR (Dynamic Client Registration).
@@ -93,7 +93,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification
**Methods:**
-#### `get_routes`
+#### `get_routes`
```python
get_routes(self) -> list[Route]
diff --git a/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx
index ba44efebe..c7aa26786 100644
--- a/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx
+++ b/docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx
@@ -44,8 +44,9 @@ Validate a redirect URI against allowed patterns.
**Args:**
- `redirect_uri`: The redirect URI to validate
-- `allowed_patterns`: List of allowed patterns. If None, defaults to localhost.
- If empty list, all URIs are allowed.
+- `allowed_patterns`: List of allowed patterns. If None, all URIs are allowed (for DCR compatibility).
+ If empty list, no URIs are allowed.
+ To restrict to localhost only, explicitly pass DEFAULT_LOCALHOST_PATTERNS.
**Returns:**
- True if the redirect URI is allowed
diff --git a/docs/python-sdk/fastmcp-server-middleware-logging.mdx b/docs/python-sdk/fastmcp-server-middleware-logging.mdx
index 9beb8300a..853725a01 100644
--- a/docs/python-sdk/fastmcp-server-middleware-logging.mdx
+++ b/docs/python-sdk/fastmcp-server-middleware-logging.mdx
@@ -8,9 +8,21 @@ sidebarTitle: logging
Comprehensive logging middleware for FastMCP servers.
+## Functions
+
+### `default_serializer`
+
+```python
+default_serializer(data: Any) -> str
+```
+
+
+The default serializer for Payloads in the logging middleware.
+
+
## Classes
-### `LoggingMiddleware`
+### `LoggingMiddleware`
Middleware that provides comprehensive request and response logging.
@@ -21,16 +33,16 @@ monitoring, and understanding server usage patterns.
**Methods:**
-#### `on_message`
+#### `on_message`
```python
-on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any
+on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any
```
Log all messages.
-### `StructuredLoggingMiddleware`
+### `StructuredLoggingMiddleware`
Middleware that provides structured JSON logging for better log analysis.
@@ -41,10 +53,10 @@ aggregation tools like ELK stack, Splunk, or cloud logging services.
**Methods:**
-#### `on_message`
+#### `on_message`
```python
-on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any
+on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any
```
Log structured message information.
diff --git a/docs/python-sdk/fastmcp-utilities-cli.mdx b/docs/python-sdk/fastmcp-utilities-cli.mdx
index 6d49ca6e0..eb4691358 100644
--- a/docs/python-sdk/fastmcp-utilities-cli.mdx
+++ b/docs/python-sdk/fastmcp-utilities-cli.mdx
@@ -7,7 +7,37 @@ sidebarTitle: cli
## Functions
-### `log_server_banner`
+### `is_already_in_uv_subprocess`
+
+```python
+is_already_in_uv_subprocess() -> bool
+```
+
+
+Check if we're already running in a FastMCP uv subprocess.
+
+
+### `load_and_merge_config`
+
+```python
+load_and_merge_config(server_spec: str | None, **cli_overrides) -> tuple[MCPServerConfig, str]
+```
+
+
+Load config from server_spec and apply CLI overrides.
+
+This consolidates the config parsing logic that was duplicated across
+run, inspect, and dev commands.
+
+**Args:**
+- `server_spec`: Python file, config file, URL, or None to auto-detect
+- `cli_overrides`: CLI arguments that override config values
+
+**Returns:**
+- Tuple of (MCPServerConfig, resolved_server_spec)
+
+
+### `log_server_banner`
```python
log_server_banner(server: FastMCP[Any], transport: Literal['stdio', 'http', 'sse', 'streamable-http']) -> None
diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-__init__.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-__init__.mdx
similarity index 84%
rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-__init__.mdx
rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-__init__.mdx
index f804aa19f..bc8fc9500 100644
--- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-__init__.mdx
+++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-__init__.mdx
@@ -3,7 +3,7 @@ title: __init__
sidebarTitle: __init__
---
-# `fastmcp.utilities.fastmcp_config`
+# `fastmcp.utilities.mcp_server_config`
FastMCP Configuration module.
diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__.mdx
similarity index 74%
rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__.mdx
rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__.mdx
index 4195f2dc2..d29664280 100644
--- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-__init__.mdx
+++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__.mdx
@@ -3,6 +3,6 @@ title: __init__
sidebarTitle: __init__
---
-# `fastmcp.utilities.fastmcp_config.v1`
+# `fastmcp.utilities.mcp_server_config.v1`
*This module is empty or contains only private/internal implementations.*
diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__.mdx
new file mode 100644
index 000000000..01f147482
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__.mdx
@@ -0,0 +1,9 @@
+---
+title: __init__
+sidebarTitle: __init__
+---
+
+# `fastmcp.utilities.mcp_server_config.v1.environments`
+
+
+Environment configuration for MCP servers.
diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx
new file mode 100644
index 000000000..3a97b5d9e
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx
@@ -0,0 +1,43 @@
+---
+title: base
+sidebarTitle: base
+---
+
+# `fastmcp.utilities.mcp_server_config.v1.environments.base`
+
+## Classes
+
+### `Environment`
+
+
+Base class for environment configuration.
+
+
+**Methods:**
+
+#### `build_command`
+
+```python
+build_command(self, command: list[str]) -> list[str]
+```
+
+Build the full command with environment setup.
+
+**Args:**
+- `command`: Base command to wrap with environment setup
+
+**Returns:**
+- Full command ready for subprocess execution
+
+
+#### `prepare`
+
+```python
+prepare(self, output_dir: Path | None = None) -> None
+```
+
+Prepare the environment (optional, can be no-op).
+
+**Args:**
+- `output_dir`: Directory for persistent environment setup
+
diff --git a/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx
new file mode 100644
index 000000000..de51b8098
--- /dev/null
+++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx
@@ -0,0 +1,75 @@
+---
+title: uv
+sidebarTitle: uv
+---
+
+# `fastmcp.utilities.mcp_server_config.v1.environments.uv`
+
+## Classes
+
+### `UVEnvironment`
+
+
+Configuration for Python environment setup.
+
+
+**Methods:**
+
+#### `build_command`
+
+```python
+build_command(self, command: list[str]) -> list[str]
+```
+
+Build complete uv run command with environment args and command to execute.
+
+**Args:**
+- `command`: Command to execute (e.g., ["fastmcp", "run", "server.py"])
+
+**Returns:**
+- Complete command ready for subprocess.run, including "uv" prefix if needed.
+- If no environment configuration is set, returns the command unchanged.
+
+
+#### `run_with_uv`
+
+```python
+run_with_uv(self, command: list[str]) -> None
+```
+
+Execute a command using uv run with this environment configuration.
+
+**Args:**
+- `command`: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"])
+
+
+#### `needs_uv`
+
+```python
+needs_uv(self) -> bool
+```
+
+Deprecated: Use _needs_setup() internally or check if build_command modifies the command.
+
+
+#### `build_uv_run_command`
+
+```python
+build_uv_run_command(self, command: list[str]) -> list[str]
+```
+
+Deprecated: Use build_command() instead.
+
+
+#### `prepare`
+
+```python
+prepare(self, output_dir: Path | None = None) -> None
+```
+
+Prepare the Python environment using uv.
+
+**Args:**
+- `output_dir`: Directory where the persistent uv project will be created.
+ If None, creates a temporary directory for ephemeral use.
+
diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx
similarity index 54%
rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config.mdx
rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx
index d66382bc1..e9d8c398d 100644
--- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-fastmcp_config.mdx
+++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx
@@ -1,9 +1,9 @@
---
-title: fastmcp_config
-sidebarTitle: fastmcp_config
+title: mcp_server_config
+sidebarTitle: mcp_server_config
---
-# `fastmcp.utilities.fastmcp_config.v1.fastmcp_config`
+# `fastmcp.utilities.mcp_server_config.v1.mcp_server_config`
FastMCP Configuration File Support.
@@ -15,7 +15,7 @@ command-line arguments.
## Functions
-### `generate_schema`
+### `generate_schema`
```python
generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | None
@@ -38,67 +38,7 @@ validation and auto-completion.
## Classes
-### `Environment`
-
-
-Configuration for Python environment setup.
-
-
-**Methods:**
-
-#### `build_uv_args`
-
-```python
-build_uv_args(self, command: str | list[str] | None = None) -> list[str]
-```
-
-Build uv run arguments from this environment configuration.
-
-**Args:**
-- `command`: Optional command to append (string or list of args)
-
-**Returns:**
-- List of arguments for uv run command
-
-
-#### `run_with_uv`
-
-```python
-run_with_uv(self, command: list[str]) -> None
-```
-
-Execute a command using uv run with this environment configuration.
-
-**Args:**
-- `command`: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"])
-
-
-#### `needs_uv`
-
-```python
-needs_uv(self) -> bool
-```
-
-Check if this environment config requires uv to set up.
-
-**Returns:**
-- True if any environment settings require uv run
-
-
-#### `prepare`
-
-```python
-prepare(self, output_dir: Path | None = None) -> None
-```
-
-Prepare the Python environment using uv.
-
-**Args:**
-- `output_dir`: Directory where the persistent uv project will be created.
- If None, creates a temporary directory for ephemeral use.
-
-
-### `Deployment`
+### `Deployment`
Configuration for server deployment and runtime settings.
@@ -106,7 +46,7 @@ Configuration for server deployment and runtime settings.
**Methods:**
-#### `apply_runtime_settings`
+#### `apply_runtime_settings`
```python
apply_runtime_settings(self, config_path: Path | None = None) -> None
@@ -122,7 +62,7 @@ For example: "API_URL": "https://api.${ENVIRONMENT}.example.com"
will substitute the value of the ENVIRONMENT variable at runtime.
-### `FastMCPConfig`
+### `MCPServerConfig`
Configuration for a FastMCP server.
@@ -133,10 +73,10 @@ a FastMCP server in a declarative format.
**Methods:**
-#### `validate_source`
+#### `validate_source`
```python
-validate_source(cls, v: dict | FileSystemSource) -> FileSystemSource
+validate_source(cls, v: dict | Source) -> SourceType
```
Validate and convert source to proper format.
@@ -146,23 +86,21 @@ Supports:
- FileSystemSource instance (passed through)
No string parsing happens here - that's only at CLI boundaries.
-FastMCPConfig works only with properly typed objects.
+MCPServerConfig works only with properly typed objects.
-#### `validate_environment`
+#### `validate_environment`
```python
-validate_environment(cls, v: dict | Environment) -> Environment
+validate_environment(cls, v: dict | Any) -> EnvironmentType
```
-Validate and convert environment to Environment.
+Ensure environment has a type field for discrimination.
-Accepts:
-- Environment instance
-- dict that can be converted to Environment
+For backward compatibility, if no type is specified, default to "uv".
-#### `validate_deployment`
+#### `validate_deployment`
```python
validate_deployment(cls, v: dict | Deployment) -> Deployment
@@ -175,10 +113,10 @@ Accepts:
- dict that can be converted to Deployment
-#### `from_file`
+#### `from_file`
```python
-from_file(cls, file_path: Path) -> FastMCPConfig
+from_file(cls, file_path: Path) -> MCPServerConfig
```
Load configuration from a JSON file.
@@ -187,7 +125,7 @@ Load configuration from a JSON file.
- `file_path`: Path to the configuration file
**Returns:**
-- FastMCPConfig instance
+- MCPServerConfig instance
**Raises:**
- `FileNotFoundError`: If the file doesn't exist
@@ -195,10 +133,10 @@ Load configuration from a JSON file.
- `pydantic.ValidationError`: If the configuration is invalid
-#### `from_cli_args`
+#### `from_cli_args`
```python
-from_cli_args(cls, source: FileSystemSource, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | None = None, python: str | None = None, dependencies: list[str] | None = None, requirements: str | None = None, project: str | None = None, editable: str | None = None, env: dict[str, str] | None = None, cwd: str | None = None, args: list[str] | None = None) -> FastMCPConfig
+from_cli_args(cls, source: FileSystemSource, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | None = None, python: str | None = None, dependencies: list[str] | None = None, requirements: str | None = None, project: str | None = None, editable: str | None = None, env: dict[str, str] | None = None, cwd: str | None = None, args: list[str] | None = None) -> MCPServerConfig
```
Create a config from CLI arguments.
@@ -223,10 +161,10 @@ goes through a config object.
- `args`: Server arguments
**Returns:**
-- FastMCPConfig instance
+- MCPServerConfig instance
-#### `find_config`
+#### `find_config`
```python
find_config(cls, start_path: Path | None = None) -> Path | None
@@ -241,7 +179,7 @@ Find a fastmcp.json file in the specified directory.
- Path to the configuration file, or None if not found
-#### `prepare`
+#### `prepare`
```python
prepare(self, skip_source: bool = False, output_dir: Path | None = None) -> None
@@ -257,7 +195,7 @@ When output_dir is None, does ephemeral caching (for backwards compatibility).
- `output_dir`: Directory to create the persistent uv project in (optional)
-#### `prepare_environment`
+#### `prepare_environment`
```python
prepare_environment(self, output_dir: Path | None = None) -> None
@@ -272,7 +210,7 @@ Prepare the Python environment.
Delegates to the environment's prepare() method
-#### `prepare_source`
+#### `prepare_source`
```python
prepare_source(self) -> None
@@ -283,7 +221,7 @@ Prepare the source for loading.
Delegates to the source's prepare() method.
-#### `run_server`
+#### `run_server`
```python
run_server(self, **kwargs: Any) -> None
diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__.mdx
similarity index 70%
rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__.mdx
rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__.mdx
index c7259c5a1..38d102b15 100644
--- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-__init__.mdx
+++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__.mdx
@@ -3,6 +3,6 @@ title: __init__
sidebarTitle: __init__
---
-# `fastmcp.utilities.fastmcp_config.v1.sources`
+# `fastmcp.utilities.mcp_server_config.v1.sources`
*This module is empty or contains only private/internal implementations.*
diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base.mdx
similarity index 58%
rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base.mdx
rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base.mdx
index 15d2876f7..0a2aa84ac 100644
--- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-base.mdx
+++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base.mdx
@@ -3,11 +3,11 @@ title: base
sidebarTitle: base
---
-# `fastmcp.utilities.fastmcp_config.v1.sources.base`
+# `fastmcp.utilities.mcp_server_config.v1.sources.base`
## Classes
-### `BaseSource`
+### `Source`
Abstract base class for all source types.
@@ -15,7 +15,7 @@ Abstract base class for all source types.
**Methods:**
-#### `prepare`
+#### `prepare`
```python
prepare(self) -> None
@@ -28,7 +28,7 @@ this method performs that preparation. For sources that don't
need preparation (e.g., local files), this is a no-op.
-#### `load_server`
+#### `load_server`
```python
load_server(self) -> Any
diff --git a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem.mdx b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx
similarity index 51%
rename from docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem.mdx
rename to docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx
index 68f3bd6fb..b557613c8 100644
--- a/docs/python-sdk/fastmcp-utilities-fastmcp_config-v1-sources-filesystem.mdx
+++ b/docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx
@@ -3,11 +3,11 @@ title: filesystem
sidebarTitle: filesystem
---
-# `fastmcp.utilities.fastmcp_config.v1.sources.filesystem`
+# `fastmcp.utilities.mcp_server_config.v1.sources.filesystem`
## Classes
-### `FileSystemSource`
+### `FileSystemSource`
Source for local Python files.
@@ -15,7 +15,7 @@ Source for local Python files.
**Methods:**
-#### `parse_path_with_object`
+#### `parse_path_with_object`
```python
parse_path_with_object(cls, v: str) -> str
@@ -27,7 +27,7 @@ This validator runs before the model is created, allowing us to
handle the "file.py:object" syntax at the model boundary.
-#### `load_server`
+#### `load_server`
```python
load_server(self) -> Any
diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx
index 7f566ff68..fadf9b03c 100644
--- a/docs/servers/auth/oauth-proxy.mdx
+++ b/docs/servers/auth/oauth-proxy.mdx
@@ -159,19 +159,46 @@ The `OAuthProxy` class provides the complete proxy implementation:
- Resource server URL (defaults to base_url)
+ Path of the FastMCP server (defaults to base_url). **Important**: This should point to your MCP endpoint path. For example, if your MCP server is accessible at `{base_url}/mcp`, specify `https://your-server.com/mcp` here for proper RFC 8707 compliance.
List of allowed redirect URI patterns for MCP clients. Patterns support wildcards (e.g., `"http://localhost:*"`, `"https://*.example.com/*"`).
- - `None` (default): Only localhost redirect URIs allowed (`http://localhost:*`, `http://127.0.0.1:*`)
- - Empty list `[]`: All redirect URIs allowed (not recommended for production)
+ - `None` (default): All redirect URIs allowed (for MCP/DCR compatibility)
+ - Empty list `[]`: No redirect URIs allowed
- Custom list: Only matching patterns allowed
These patterns apply to MCP client loopback redirects, NOT the upstream OAuth app redirect URI.
+### Dynamic client scope
+
+When `OAuthProxy` creates a dynamic client (`ProxyDCRClient`) during registration or for temporary/unregistered access, it sets the client's `scope` string from your `TokenVerifier.required_scopes` (joined with spaces). If `required_scopes` is empty or `None`, the client's `scope` will be an empty string.
+
+```python
+from fastmcp.server.auth.providers.jwt import JWTVerifier
+from fastmcp.server.auth.oauth_proxy import OAuthProxy
+
+token_verifier = JWTVerifier(
+ jwks_uri="https://provider/.well-known/jwks.json",
+ issuer="https://provider",
+ audience="my-app",
+)
+token_verifier.required_scopes = ["read", "write"]
+
+auth = OAuthProxy(
+ upstream_authorization_endpoint="https://provider/authorize",
+ upstream_token_endpoint="https://provider/token",
+ upstream_client_id="cid",
+ upstream_client_secret="secret",
+ token_verifier=token_verifier,
+ base_url="https://your-server.com",
+)
+
+# Any dynamic client created by the proxy will have scope "read write"
+```
+
```python
from fastmcp import FastMCP
from fastmcp.server.auth import OAuthProxy
@@ -201,7 +228,10 @@ auth = OAuthProxy(
base_url="https://your-server.com",
# Optional: customize callback path (defaults to "/auth/callback")
- redirect_path="/auth/callback"
+ redirect_path="/auth/callback",
+
+ # Optional: specify MCP endpoint path if different from base_url
+ # resource_server_url="https://your-server.com/mcp"
)
mcp = FastMCP(name="My Server", auth=auth)
@@ -230,29 +260,45 @@ The proxy automatically:
## Client Redirect URI Security
-
-By default, OAuth Proxy only accepts localhost redirect URIs from MCP clients for security. You can customize this with the `allowed_client_redirect_uris` parameter:
+
+OAuth Proxy accepts all redirect URIs by default to maintain compatibility with MCP's Dynamic Client Registration (DCR) pattern, where clients register with unpredictable redirect URIs.
+
+If you know which clients will connect, you can restrict redirect URIs using the `allowed_client_redirect_uris` parameter:
```python
-# Default: localhost only (secure)
+# Default: allow all (for DCR compatibility)
auth = OAuthProxy(...)
+# Restrict to localhost only
+auth = OAuthProxy(
+ ...,
+ allowed_client_redirect_uris=[
+ "http://localhost:*",
+ "http://127.0.0.1:*"
+ ]
+)
+
+# Allow specific known clients (e.g., Claude.ai)
+auth = OAuthProxy(
+ ...,
+ allowed_client_redirect_uris=[
+ "http://localhost:*",
+ "https://claude.ai/api/mcp/auth_callback"
+ ]
+)
+
# Custom patterns with wildcards
auth = OAuthProxy(
...,
allowed_client_redirect_uris=[
"http://localhost:*",
- "https://app.example.com/auth/*"
+ "https://*.example.com/auth/*"
]
)
-
-# Allow all (NOT recommended for production)
-auth = OAuthProxy(
- ...,
- allowed_client_redirect_uris=[]
-)
```
-
+
+**Tip:** Check your server logs for debug messages that say "Client registered with redirect_uri" messages to see what redirect URIs your clients are using.
+
## Client Compatibility
diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx
index 5f6a485cd..6ffe876e5 100644
--- a/docs/servers/auth/remote-oauth.mdx
+++ b/docs/servers/auth/remote-oauth.mdx
@@ -111,7 +111,7 @@ token_verifier = JWTVerifier(
auth = RemoteAuthProvider(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")],
- resource_server_url="https://api.yourcompany.com",
+ resource_server_url="https://api.yourcompany.com/mcp", # Point to your MCP endpoint
# Optional: customize allowed client redirect URIs (defaults to localhost only)
allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"]
)
@@ -121,7 +121,7 @@ mcp = FastMCP(name="Company API", auth=auth)
This configuration creates a server that accepts tokens issued by `auth.yourcompany.com` and provides the OAuth discovery metadata that MCP clients need. The `JWTVerifier` handles token validation using your identity provider's public keys, while the `RemoteAuthProvider` generates the required OAuth endpoints.
-The `authorization_servers` list tells MCP clients which identity providers you trust. The `resource_server_url` identifies your server in OAuth metadata, enabling proper token audience validation.
+The `authorization_servers` list tells MCP clients which identity providers you trust. The `resource_server_url` identifies your server in OAuth metadata, enabling proper token audience validation. **Important**: The `resource_server_url` should point to your actual MCP endpoint - for example, if your MCP server is accessible at `https://api.yourcompany.com/mcp`, use that full path rather than just the base URL.
### Custom Endpoints
@@ -143,7 +143,7 @@ class CompanyAuthProvider(RemoteAuthProvider):
super().__init__(
token_verifier=token_verifier,
authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")],
- resource_server_url="https://api.yourcompany.com"
+ resource_server_url="https://api.yourcompany.com/mcp" # Your MCP endpoint path
)
def get_routes(self) -> list[Route]:
diff --git a/examples/auth/azure_oauth/server.py b/examples/auth/azure_oauth/server.py
index 2d5062612..81687cfd4 100644
--- a/examples/auth/azure_oauth/server.py
+++ b/examples/auth/azure_oauth/server.py
@@ -23,6 +23,7 @@ auth = AzureProvider(
tenant_id=os.getenv("AZURE_TENANT_ID")
or "", # Required for single-tenant apps - get from Azure Portal
base_url="http://localhost:8000",
+ resource_server_url="http://localhost:8000/mcp",
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
)
diff --git a/examples/auth/github_oauth/server.py b/examples/auth/github_oauth/server.py
index 1f88c6977..a0fdc0504 100644
--- a/examples/auth/github_oauth/server.py
+++ b/examples/auth/github_oauth/server.py
@@ -19,6 +19,7 @@ auth = GitHubProvider(
client_id=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID") or "",
client_secret=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET") or "",
base_url="http://localhost:8000",
+ resource_server_url="http://localhost:8000/mcp",
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
)
diff --git a/examples/auth/google_oauth/server.py b/examples/auth/google_oauth/server.py
index 2a5b1c7df..feb1fe1d4 100644
--- a/examples/auth/google_oauth/server.py
+++ b/examples/auth/google_oauth/server.py
@@ -19,6 +19,7 @@ auth = GoogleProvider(
client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID") or "",
client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET") or "",
base_url="http://localhost:8000",
+ resource_server_url="http://localhost:8000/mcp",
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
# Optional: specify required scopes
# required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"],
diff --git a/examples/auth/workos_oauth/server.py b/examples/auth/workos_oauth/server.py
index 08c1db62b..8db24f13b 100644
--- a/examples/auth/workos_oauth/server.py
+++ b/examples/auth/workos_oauth/server.py
@@ -21,6 +21,7 @@ auth = WorkOSProvider(
client_secret=os.getenv("WORKOS_CLIENT_SECRET") or "",
authkit_domain=os.getenv("WORKOS_AUTHKIT_DOMAIN") or "https://your-app.authkit.app",
base_url="http://localhost:8000",
+ resource_server_url="http://localhost:8000/mcp",
# redirect_path="/auth/callback", # Default path - change if using a different callback URL
)
diff --git a/examples/fastmcp_config_demo/README.md b/examples/fastmcp_config_demo/README.md
index 23c17975b..7abf11c3d 100644
--- a/examples/fastmcp_config_demo/README.md
+++ b/examples/fastmcp_config_demo/README.md
@@ -26,14 +26,14 @@ With the configuration file in place, you can run the server in several ways:
```bash
# Auto-detect fastmcp.json in current directory
-cd examples/fastmcp_config_demo
+cd examples/mcp_server_config_demo
fastmcp run
# Or specify the config file explicitly
-fastmcp run examples/fastmcp_config_demo/fastmcp.json
+fastmcp run examples/mcp_server_config_demo/fastmcp.json
# Or use development mode with the Inspector UI
-fastmcp dev examples/fastmcp_config_demo/fastmcp.json
+fastmcp dev examples/mcp_server_config_demo/fastmcp.json
```
## Benefits
diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py
index 8873a3a7a..1a97defb6 100644
--- a/src/fastmcp/cli/claude.py
+++ b/src/fastmcp/cli/claude.py
@@ -6,8 +6,8 @@ import sys
from pathlib import Path
from typing import Any
-from fastmcp.utilities.fastmcp_config import Environment
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
logger = get_logger(__name__)
@@ -98,12 +98,11 @@ def update_claude_config(
if not deduplicated_packages:
deduplicated_packages = None
- # Build uv run command using Environment.build_uv_args()
- env_config = Environment(
+ # Build uv run command using Environment.build_uv_run_command()
+ env_config = UVEnvironment(
dependencies=deduplicated_packages,
editable=[str(p) for p in 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
@@ -113,10 +112,14 @@ def update_claude_config(
else:
file_spec = str(Path(file_spec).resolve())
- # Add fastmcp run command
- args.extend(["fastmcp", "run", file_spec])
+ # Build the full command
+ full_command = env_config.build_command(["fastmcp", "run", file_spec])
- server_config: dict[str, Any] = {"command": "uv", "args": args}
+ # Extract command and args for the config
+ server_config: dict[str, Any] = {
+ "command": full_command[0],
+ "args": full_command[1:],
+ }
# Add environment variables if specified
if env_vars:
diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py
index 34e1a908c..076277932 100644
--- a/src/fastmcp/cli/cli.py
+++ b/src/fastmcp/cli/cli.py
@@ -13,7 +13,6 @@ from typing import Annotated, Literal
import cyclopts
import pyperclip
-from pydantic import ValidationError
from rich.console import Console
from rich.table import Table
@@ -21,15 +20,14 @@ 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.fastmcp_config import Environment, FastMCPConfig
-from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
from fastmcp.utilities.inspect import (
InspectFormat,
format_info,
inspect_fastmcp,
)
from fastmcp.utilities.logging import get_logger
-from fastmcp.utilities.types import get_cached_typeadapter
+from fastmcp.utilities.mcp_server_config import MCPServerConfig
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
logger = get_logger("cli")
console = Console()
@@ -195,75 +193,33 @@ async def dev(
Args:
server_spec: Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json
"""
- # Convert None to empty lists for list parameters
- with_editable = with_editable or []
- with_packages = with_packages or []
- from pathlib import Path
+ from fastmcp.utilities.cli import load_and_merge_config
- from fastmcp.utilities.fastmcp_config import FastMCPConfig
- from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
+ try:
+ # Load config and apply CLI overrides
+ config, server_spec = load_and_merge_config(
+ server_spec,
+ python=python,
+ with_packages=with_packages or [],
+ with_requirements=with_requirements,
+ project=project,
+ editable=[str(p) for p in with_editable] if with_editable else None,
+ port=server_port, # Use deployment config for server port
+ )
- config = None
- config_path = None
+ # Get server port from config if not specified via CLI
+ if not server_port:
+ server_port = config.deployment.port
- # Auto-detect fastmcp.json if no server_spec provided
- if server_spec is None:
- config_path = Path("fastmcp.json")
- if not config_path.exists():
- # Check if fastmcp.json exists in current directory
- found_config = FastMCPConfig.find_config()
- if found_config:
- config_path = found_config
- else:
- logger.error(
- "No server specification provided and no fastmcp.json found in current directory.\n"
- "Please specify a server file or create a fastmcp.json configuration."
- )
- sys.exit(1)
- server_spec = str(config_path)
- logger.info(f"Using configuration from {config_path}")
-
- # Create FastMCPConfig from server_spec
- if server_spec.endswith(".json"):
- # Load existing config
- config = FastMCPConfig.from_file(Path(server_spec))
-
- # Merge environment settings with CLI args (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 editable paths from config with CLI args
- if config.environment.editable and not with_editable:
- with_editable = [Path(p) for p in 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
-
- # Get server port from deployment config if not specified
- if config.deployment and config.deployment.port:
- server_port = server_port or config.deployment.port
- else:
- # Create config from file path
- source = FileSystemSource(path=server_spec)
- config = FastMCPConfig(source=source)
+ except FileNotFoundError:
+ sys.exit(1)
logger.debug(
"Starting dev server",
extra={
"server_spec": server_spec,
- "with_editable": [str(p) for p in with_editable] if with_editable else None,
- "with_packages": with_packages,
+ "with_editable": config.environment.editable,
+ "with_packages": config.environment.dependencies,
"ui_port": ui_port,
"server_port": server_port,
},
@@ -271,6 +227,10 @@ async def dev(
try:
# Load server to check for deprecated dependencies
+ if not config:
+ logger.error("No configuration available")
+ sys.exit(1)
+ assert config is not None # For type checker
server: FastMCP = await config.source.load_server()
if server.dependencies:
import warnings
@@ -282,7 +242,13 @@ async def dev(
DeprecationWarning,
stacklevel=2,
)
- with_packages = list(set(with_packages + server.dependencies))
+ # Merge server dependencies with environment dependencies
+ env_deps = config.environment.dependencies or []
+ all_deps = list(set(env_deps + server.dependencies))
+ if not config.environment:
+ config.environment = UVEnvironment(dependencies=all_deps)
+ else:
+ config.environment.dependencies = all_deps
env_vars = {}
if ui_port:
@@ -303,18 +269,13 @@ async def dev(
if inspector_version:
inspector_cmd += f"@{inspector_version}"
- # Create Environment object from CLI args
- 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(p) for p in with_editable] if with_editable else None,
+ # Use the environment from config (already has CLI overrides applied)
+ uv_cmd = config.environment.build_command(
+ ["fastmcp", "run", server_spec, "--no-banner"]
)
- uv_cmd = ["uv"] + env_config.build_uv_args(["fastmcp", "run", server_spec])
- # Add --no-banner flag for dev command
- uv_cmd.append("--no-banner")
+ # Set marker to prevent infinite loops when subprocess calls FastMCP
+ env = dict(os.environ.items()) | env_vars | {"FASTMCP_UV_SPAWNED": "1"}
# Run the MCP Inspector command with shell=True on Windows
shell = sys.platform == "win32"
@@ -322,7 +283,7 @@ async def dev(
[npx_cmd, inspector_cmd] + uv_cmd,
check=True,
shell=shell,
- env=dict(os.environ.items()) | env_vars,
+ env=env,
)
sys.exit(process.returncode)
except subprocess.CalledProcessError as e:
@@ -454,135 +415,76 @@ async def run(
Args:
server_spec: Python file, object specification (file:obj), config file, URL, or None to auto-detect
"""
- # Convert None to empty lists for list parameters
- with_packages = with_packages or []
- # Load configuration if needed
- from pathlib import Path
+ from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
- from fastmcp.utilities.fastmcp_config import FastMCPConfig
+ # Check if we were spawned by uv (or user explicitly set --skip-env)
+ if skip_env or is_already_in_uv_subprocess():
+ skip_env = True
- config = None
- config_path = None
- editable = None # Initialize editable variable
+ try:
+ # Load config and apply CLI overrides
+ config, server_spec = load_and_merge_config(
+ server_spec,
+ python=python,
+ with_packages=with_packages or [],
+ with_requirements=with_requirements,
+ project=project,
+ transport=transport,
+ host=host,
+ port=port,
+ path=path,
+ log_level=log_level,
+ server_args=list(server_args) if server_args else None,
+ )
+ except FileNotFoundError:
+ sys.exit(1)
- # Auto-detect fastmcp.json if no server_spec provided
- if server_spec is None:
- config_path = Path("fastmcp.json")
- if not config_path.exists():
- # Check if fastmcp.json exists in current directory
- found_config = FastMCPConfig.find_config()
- if found_config:
- config_path = found_config
- else:
- logger.error(
- "No server specification provided and no fastmcp.json found in current directory.\n"
- "Please specify a server file or create a fastmcp.json configuration."
- )
- sys.exit(1)
+ # Get effective values (CLI overrides take precedence)
+ final_transport = transport or config.deployment.transport
+ final_host = host or config.deployment.host
+ final_port = port or config.deployment.port
+ final_path = path or config.deployment.path
+ final_log_level = log_level or config.deployment.log_level
+ final_server_args = server_args or config.deployment.args
- server_spec = str(config_path)
- logger.info(f"Using configuration from {config_path}")
-
- # Load config if server_spec is a .json file
- if server_spec.endswith(".json"):
- config_path = Path(server_spec)
- if config_path.exists():
- # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig
- try:
- 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 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 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)
- # BUT: Skip this if --skip-env is set
- if config.environment and not skip_env:
- 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={
"server_spec": server_spec,
- "transport": transport,
- "host": host,
- "port": port,
- "path": path,
- "log_level": log_level,
- "server_args": list(server_args),
+ "transport": final_transport,
+ "host": final_host,
+ "port": final_port,
+ "path": final_path,
+ "log_level": final_log_level,
+ "server_args": list(final_server_args) if final_server_args else [],
},
)
- # Check if we need to use uv run (either from CLI args or config)
- # When --skip-env is set, we ignore config.environment entirely
- needs_uv = python or with_packages or with_requirements or project or editable
- if not needs_uv and config and config.environment and not skip_env:
- # Check if config's environment needs uv (but only if not skipping env)
- needs_uv = config.environment.needs_uv()
+ # Check if we need to use uv run (but skip if we're already in uv or user said to skip)
+ # We check if the environment would modify the command
+ test_cmd = ["test"]
+ needs_uv = config.environment.build_command(test_cmd) != test_cmd and not skip_env
if needs_uv:
# Use uv run subprocess - always use run_with_uv which handles output correctly
try:
run_module.run_with_uv(
server_spec=server_spec,
- python_version=python,
- with_packages=with_packages,
- with_requirements=with_requirements,
- project=project,
- transport=transport,
- host=host,
- port=port,
- path=path,
- log_level=log_level,
+ python_version=config.environment.python,
+ with_packages=config.environment.dependencies,
+ with_requirements=Path(config.environment.requirements)
+ if config.environment.requirements
+ else None,
+ project=Path(config.environment.project)
+ if config.environment.project
+ else None,
+ transport=final_transport,
+ host=final_host,
+ port=final_port,
+ path=final_path,
+ log_level=final_log_level,
show_banner=not no_banner,
- editable=editable,
+ editable=config.environment.editable,
)
except Exception as e:
logger.error(
@@ -598,12 +500,12 @@ async def run(
try:
await run_module.run_command(
server_spec=server_spec,
- transport=transport,
- host=host,
- port=port,
- path=path,
- log_level=log_level,
- server_args=list(server_args),
+ transport=final_transport,
+ host=final_host,
+ port=final_port,
+ path=final_path,
+ log_level=final_log_level,
+ server_args=list(final_server_args) if final_server_args else [],
show_banner=not no_banner,
skip_source=skip_source,
)
@@ -696,100 +598,49 @@ async def inspect(
Args:
server_spec: Python file to inspect, optionally with :object suffix, or fastmcp.json
"""
- # Convert None to empty lists for list parameters
- with_packages = with_packages or []
- config = None
- config_path = None
+ from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config
- # Auto-detect fastmcp.json if no server_spec provided
- if server_spec is None:
- config_path = Path("fastmcp.json")
- if not config_path.exists():
- # Check if fastmcp.json exists in current directory
- found_config = FastMCPConfig.find_config()
- if found_config:
- config_path = found_config
- else:
- logger.error(
- "No server specification provided and no fastmcp.json found in current directory.\n"
- "Please specify a server file or create a fastmcp.json configuration."
- )
- sys.exit(1)
+ # Check if we were spawned by uv (or user explicitly set --skip-env)
+ if skip_env or is_already_in_uv_subprocess():
+ skip_env = True
- server_spec = str(config_path)
- logger.info(f"Using configuration from {config_path}")
+ try:
+ # Load config and apply CLI overrides
+ config, server_spec = load_and_merge_config(
+ server_spec,
+ python=python,
+ with_packages=with_packages or [],
+ with_requirements=with_requirements,
+ project=project,
+ )
- # Create FastMCPConfig from server_spec
- if server_spec.endswith(".json"):
- config_path = Path(server_spec)
- if config_path.exists():
+ # Check if it's an MCPConfig (which inspect doesn't support)
+ if server_spec.endswith(".json") and config is None:
+ # This might be an MCPConfig, check the file
try:
- with open(config_path) as f:
+ with open(Path(server_spec)) as f:
data = json.load(f)
-
- # Check if it's an MCPConfig (has mcpServers key)
if "mcpServers" in data:
- # MCPConfig - we don't process these in inspect
logger.error("MCPConfig files are not supported by inspect command")
sys.exit(1)
- else:
- # It's a FastMCPConfig
- config = FastMCPConfig.from_file(config_path)
+ except (json.JSONDecodeError, FileNotFoundError):
+ pass
- # 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
- )
+ except FileNotFoundError:
+ sys.exit(1)
- # 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 (json.JSONDecodeError, ValidationError) as e:
- logger.error(f"Invalid configuration file: {e}")
- sys.exit(1)
- else:
- logger.error(f"Configuration file not found: {config_path}")
- sys.exit(1)
- else:
- # Create config from file path
- source = FileSystemSource(path=server_spec)
- config = FastMCPConfig(source=source)
-
- # Check if we need to use uv run (skip if --skip-env is set)
- needs_uv = False
- if not skip_env:
- needs_uv = python or with_packages or with_requirements or project
- if not needs_uv and config and config.environment:
- needs_uv = config.environment.needs_uv()
+ # Check if we need to use uv run (but skip if we're already in uv or user said to skip)
+ # We check if the environment would modify the command
+ test_cmd = ["test"]
+ needs_uv = config.environment.build_command(test_cmd) != test_cmd and not skip_env
if needs_uv:
# Build and run uv command
-
- # Create or update environment config
- 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,
- )
-
+ # The environment is already configured in the config object
inspect_command = [
"fastmcp",
"inspect",
server_spec,
- "--skip-env", # Prevent infinite loop when calling through uv
]
# Add format and output flags if specified
@@ -797,8 +648,14 @@ async def inspect(
inspect_command.extend(["--format", format.value])
if output:
inspect_command.extend(["--output", str(output)])
- env_config.run_with_uv(inspect_command)
- return # run_with_uv exits the process
+
+ # Run the command using subprocess
+ import subprocess
+
+ cmd = config.environment.build_command(inspect_command)
+ env = os.environ | {"FASTMCP_UV_SPAWNED": "1"}
+ process = subprocess.run(cmd, check=True, env=env)
+ sys.exit(process.returncode)
logger.debug(
"Inspecting server",
@@ -811,6 +668,10 @@ async def inspect(
try:
# Load the server using the config
+ if not config:
+ logger.error("No configuration available")
+ sys.exit(1)
+ assert config is not None # For type checker
server = await config.source.load_server()
# Get basic server information
@@ -936,8 +797,6 @@ async def prepare(
"""
from pathlib import Path
- from fastmcp.utilities.fastmcp_config import FastMCPConfig
-
# Require output-dir
if output_dir is None:
logger.error(
@@ -948,7 +807,7 @@ async def prepare(
# Auto-detect fastmcp.json if not provided
if config_path is None:
- found_config = FastMCPConfig.find_config()
+ found_config = MCPServerConfig.find_config()
if found_config:
config_path = str(found_config)
logger.info(f"Using configuration from {config_path}")
@@ -968,7 +827,7 @@ async def prepare(
try:
# Load the configuration
- config = FastMCPConfig.from_file(config_file)
+ config = MCPServerConfig.from_file(config_file)
# Prepare environment and source
await config.prepare(
diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py
index aaa07503c..e3ecbf26c 100644
--- a/src/fastmcp/cli/install/claude_code.py
+++ b/src/fastmcp/cli/install/claude_code.py
@@ -9,8 +9,8 @@ from typing import Annotated
import cyclopts
from rich import print
-from fastmcp.utilities.fastmcp_config import Environment
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
from .shared import process_common_args
@@ -115,15 +115,14 @@ def install_claude_code(
if not deduplicated_packages:
deduplicated_packages = None
- # Build uv run command using Environment.build_uv_args()
- env_config = Environment(
+ # Build uv run command using Environment.build_uv_run_command()
+ env_config = UVEnvironment(
python=python_version,
dependencies=deduplicated_packages,
requirements=str(with_requirements) if with_requirements else None,
project=str(project) if project else None,
editable=[str(p) for p in with_editable] if with_editable else None,
)
- args = env_config.build_uv_args()
# Build server spec from parsed components
if server_object:
@@ -131,8 +130,8 @@ def install_claude_code(
else:
server_spec = str(file.resolve())
- # Add fastmcp run command
- args.extend(["fastmcp", "run", server_spec])
+ # Build the full command
+ full_command = env_config.build_command(["fastmcp", "run", server_spec])
# Build claude mcp add command
cmd_parts = [claude_cmd, "mcp", "add"]
@@ -144,7 +143,7 @@ def install_claude_code(
# Add server name and command
cmd_parts.extend([name, "--"])
- cmd_parts.extend(["uv"] + args)
+ cmd_parts.extend(full_command)
try:
# Run the claude mcp add command
diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py
index 28ce20eee..44bca8c70 100644
--- a/src/fastmcp/cli/install/claude_desktop.py
+++ b/src/fastmcp/cli/install/claude_desktop.py
@@ -9,8 +9,8 @@ import cyclopts
from rich import print
from fastmcp.mcp_config import StdioMCPServer, update_config_file
-from fastmcp.utilities.fastmcp_config import Environment
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
from .shared import process_common_args
@@ -81,28 +81,26 @@ def install_claude_desktop(
if not deduplicated_packages:
deduplicated_packages = None
- env_config = Environment(
+ env_config = UVEnvironment(
python=python_version,
dependencies=deduplicated_packages,
requirements=str(with_requirements) if with_requirements else None,
project=str(project) if project else None,
editable=[str(p) for p in with_editable] if with_editable else None,
)
- args = env_config.build_uv_args()
-
# Build server spec from parsed components
if server_object:
server_spec = f"{file.resolve()}:{server_object}"
else:
server_spec = str(file.resolve())
- # Add fastmcp run command
- args.extend(["fastmcp", "run", server_spec])
+ # Build the full command
+ full_command = env_config.build_command(["fastmcp", "run", server_spec])
# Create server configuration
server_config = StdioMCPServer(
- command="uv",
- args=args,
+ command=full_command[0],
+ args=full_command[1:],
env=env_vars or {},
)
diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py
index bbb94a872..387787367 100644
--- a/src/fastmcp/cli/install/cursor.py
+++ b/src/fastmcp/cli/install/cursor.py
@@ -10,8 +10,8 @@ import cyclopts
from rich import print
from fastmcp.mcp_config import StdioMCPServer, update_config_file
-from fastmcp.utilities.fastmcp_config import Environment
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
from .shared import process_common_args
@@ -115,28 +115,26 @@ def install_cursor_workspace(
if not deduplicated_packages:
deduplicated_packages = None
- env_config = Environment(
+ env_config = UVEnvironment(
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(p.resolve()) for p in with_editable] if with_editable else None,
)
- args = env_config.build_uv_args()
-
# Build server spec from parsed components
if server_object:
server_spec = f"{file.resolve()}:{server_object}"
else:
server_spec = str(file.resolve())
- # Add fastmcp run command
- args.extend(["fastmcp", "run", server_spec])
+ # Build the full command
+ full_command = env_config.build_command(["fastmcp", "run", server_spec])
# Create server configuration
server_config = StdioMCPServer(
- command="uv",
- args=args,
+ command=full_command[0],
+ args=full_command[1:],
env=env_vars or {},
)
@@ -195,23 +193,21 @@ def install_cursor(
if not deduplicated_packages:
deduplicated_packages = None
- env_config = Environment(
+ env_config = UVEnvironment(
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(p.resolve()) for p in with_editable] if with_editable else None,
)
- args = env_config.build_uv_args()
-
# Build server spec from parsed components
if server_object:
server_spec = f"{file.resolve()}:{server_object}"
else:
server_spec = str(file.resolve())
- # Add fastmcp run command
- args.extend(["fastmcp", "run", server_spec])
+ # Build the full command
+ full_command = env_config.build_command(["fastmcp", "run", server_spec])
# If workspace is specified, install to workspace-specific config
if workspace:
@@ -230,8 +226,8 @@ def install_cursor(
# Create server configuration
server_config = StdioMCPServer(
- command="uv",
- args=args,
+ command=full_command[0],
+ args=full_command[1:],
env=env_vars or {},
)
diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py
index 42696ae6c..aa860eebf 100644
--- a/src/fastmcp/cli/install/mcp_json.py
+++ b/src/fastmcp/cli/install/mcp_json.py
@@ -9,8 +9,8 @@ import cyclopts
import pyperclip
from rich import print
-from fastmcp.utilities.fastmcp_config import Environment
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
from .shared import process_common_args
@@ -56,28 +56,26 @@ def install_mcp_json(
if not deduplicated_packages:
deduplicated_packages = None
- env_config = Environment(
+ env_config = UVEnvironment(
python=python_version,
dependencies=deduplicated_packages,
requirements=str(with_requirements) if with_requirements else None,
project=str(project) if project else None,
editable=[str(p) for p in with_editable] if with_editable else None,
)
- args = env_config.build_uv_args()
-
# Build server spec from parsed components
if server_object:
server_spec = f"{file.resolve()}:{server_object}"
else:
server_spec = str(file.resolve())
- # Add fastmcp run command
- args.extend(["fastmcp", "run", server_spec])
+ # Build the full command
+ full_command = env_config.build_command(["fastmcp", "run", server_spec])
# Build MCP server configuration
server_config = {
- "command": "uv",
- "args": args,
+ "command": full_command[0],
+ "args": full_command[1:],
}
# Add environment variables if provided
diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py
index 6db83403f..3544a2b6b 100644
--- a/src/fastmcp/cli/install/shared.py
+++ b/src/fastmcp/cli/install/shared.py
@@ -8,9 +8,9 @@ from dotenv import dotenv_values
from pydantic import ValidationError
from rich import print
-from fastmcp.utilities.fastmcp_config import FastMCPConfig
-from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.mcp_server_config import MCPServerConfig
+from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
logger = get_logger(__name__)
@@ -40,7 +40,7 @@ async def process_common_args(
# Convert None to empty lists for list parameters
with_packages = with_packages or []
env_vars = env_vars or []
- # Create FastMCPConfig from server_spec
+ # Create MCPServerConfig from server_spec
config = None
if server_spec.endswith(".json"):
config_path = Path(server_spec).resolve()
@@ -58,13 +58,13 @@ async def process_common_args(
print("[red]MCPConfig files are not supported for installation[/red]")
sys.exit(1)
else:
- # It's a FastMCPConfig
- config = FastMCPConfig.from_file(config_path)
+ # It's a MCPServerConfig
+ config = MCPServerConfig.from_file(config_path)
# Merge packages from config if not overridden
- if config.environment and config.environment.dependencies:
+ if config.environment.dependencies:
# Merge with CLI packages (CLI takes precedence)
- config_packages = list(config.environment.dependencies) or []
+ config_packages = list(config.environment.dependencies)
with_packages = list(set(with_packages + config_packages))
except (json.JSONDecodeError, ValidationError) as e:
print(f"[red]Invalid configuration file: {e}[/red]")
@@ -72,7 +72,7 @@ async def process_common_args(
else:
# Create config from file path
source = FileSystemSource(path=server_spec)
- config = FastMCPConfig(source=source)
+ config = MCPServerConfig(source=source)
# Extract file and server_object from the source
# The FileSystemSource handles parsing path:object syntax
diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py
index 47302f210..f63737ed8 100644
--- a/src/fastmcp/cli/run.py
+++ b/src/fastmcp/cli/run.py
@@ -1,6 +1,7 @@
"""FastMCP run command implementation with enhanced type hints."""
import json
+import os
import re
import subprocess
import sys
@@ -8,16 +9,14 @@ 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.fastmcp_config import (
- Environment,
- FastMCPConfig,
-)
-from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
from fastmcp.utilities.logging import get_logger
-from fastmcp.utilities.types import get_cached_typeadapter
+from fastmcp.utilities.mcp_server_config import (
+ MCPServerConfig,
+)
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
+from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
logger = get_logger("cli.run")
@@ -48,6 +47,10 @@ def run_with_uv(
) -> None:
"""Run a MCP server using uv run subprocess.
+ This function is called when we need to set up a Python environment with specific
+ dependencies before running the server. The config parsing and merging should already
+ be done by the caller.
+
Args:
server_spec: Python file, object specification (file:obj), config file, or URL
python_version: Python version to use (e.g. "3.10")
@@ -60,71 +63,11 @@ def run_with_uv(
path: Path to bind to when using http transport
log_level: Log level
show_banner: Whether to show the server banner
+ editable: Editable package paths
"""
- # 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():
- # Try to load as JSON and discriminate between FastMCPConfig and MCPConfig
- try:
- 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 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)
-
- # Apply deployment settings
- if config.deployment:
- config.deployment.apply_runtime_settings(config_path)
-
- # 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
- )
- # Note: config editable is a list but CLI currently only supports single path
- # Just pass through for now - Environment will handle the list
- if not editable and config.environment.editable:
- editable = 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(
+ # Build uv command using Environment.build_uv_run_command()
+ env_config = UVEnvironment(
python=python_version,
dependencies=with_packages if with_packages else None,
requirements=str(with_requirements.resolve()) if with_requirements else None,
@@ -133,9 +76,9 @@ def run_with_uv(
if isinstance(editable, list)
else ([editable] if editable else None),
)
- # Build the uv command
- # Build the inner fastmcp command with --skip-env to prevent infinite recursion
- inner_cmd = ["fastmcp", "run", "--skip-env", server_spec]
+
+ # Build the inner fastmcp command (environment variable prevents infinite recursion)
+ inner_cmd = ["fastmcp", "run", server_spec]
# Add transport options to the inner command
if transport:
@@ -154,13 +97,15 @@ def run_with_uv(
inner_cmd.append("--no-banner")
# Build the full uv command
- uv_args = env_config.build_uv_args(inner_cmd)
- cmd = ["uv"] + uv_args
+ cmd = env_config.build_command(inner_cmd)
+
+ # Set marker to prevent infinite loops when subprocess calls FastMCP again
+ env = os.environ | {"FASTMCP_UV_SPAWNED": "1"}
# Run the command
logger.debug(f"Running command: {' '.join(cmd)}")
try:
- process = subprocess.run(cmd, check=True)
+ process = subprocess.run(cmd, check=True, env=env)
sys.exit(process.returncode)
except subprocess.CalledProcessError as e:
logger.error(f"Failed to run server: {e}")
@@ -198,20 +143,19 @@ def create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None]:
return server
-def load_fastmcp_config(config_path: Path) -> FastMCPConfig:
+def load_mcp_server_config(config_path: Path) -> MCPServerConfig:
"""Load a FastMCP configuration from a fastmcp.json file.
Args:
config_path: Path to fastmcp.json file
Returns:
- FastMCPConfig object
+ MCPServerConfig object
"""
- config = FastMCPConfig.from_file(config_path)
+ config = MCPServerConfig.from_file(config_path)
# Apply runtime settings from deployment config
- if config.deployment:
- config.deployment.apply_runtime_settings(config_path)
+ config.deployment.apply_runtime_settings(config_path)
return config
@@ -260,18 +204,17 @@ async def run_command(
server = create_mcp_config_server(config_path)
else:
# It's a FastMCP config - load it properly
- config = load_fastmcp_config(config_path)
+ config = load_mcp_server_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
- )
+ 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
+ )
# Prepare source only (environment is handled by uv run)
await config.prepare_source() if not skip_source else None
@@ -289,9 +232,9 @@ async def run_command(
logger.debug(f'Found server "{server.name}" from config {config_path}')
else:
- # Regular file case - create a FastMCPConfig with FileSystemSource
+ # Regular file case - create a MCPServerConfig with FileSystemSource
source = FileSystemSource(path=server_spec)
- config = FastMCPConfig(source=source)
+ config = MCPServerConfig(source=source)
# Prepare source only (environment is handled by uv run)
await config.prepare_source() if not skip_source else None
diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py
index 88a8a9d33..b589c2873 100644
--- a/src/fastmcp/client/auth/oauth.py
+++ b/src/fastmcp/client/auth/oauth.py
@@ -4,6 +4,7 @@ import asyncio
import json
import webbrowser
from asyncio import Future
+from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Literal
from urllib.parse import urlparse
@@ -18,7 +19,7 @@ from mcp.shared.auth import (
from mcp.shared.auth import (
OAuthToken as OAuthToken,
)
-from pydantic import AnyHttpUrl, ValidationError
+from pydantic import AnyHttpUrl, BaseModel, TypeAdapter, ValidationError
from uvicorn.server import Server
from fastmcp import settings as fastmcp_global_settings
@@ -33,6 +34,17 @@ __all__ = ["OAuth"]
logger = get_logger(__name__)
+class StoredToken(BaseModel):
+ """Token storage format with absolute expiry time."""
+
+ token_payload: OAuthToken
+ expires_at: datetime | None
+
+
+# Create TypeAdapter at module level for efficient parsing
+stored_token_adapter = TypeAdapter(StoredToken)
+
+
def default_cache_dir() -> Path:
return fastmcp_global_settings.home / "oauth-mcp-client-cache"
@@ -77,13 +89,28 @@ class FileTokenStorage(TokenStorage):
path = self._get_file_path("tokens")
try:
- tokens = OAuthToken.model_validate_json(path.read_text())
- # now = datetime.datetime.now(datetime.timezone.utc)
- # if tokens.expires_at is not None and tokens.expires_at <= now:
- # logger.debug(f"Token expired for {self.get_base_url(self.server_url)}")
- # return None
- return tokens
- except (FileNotFoundError, json.JSONDecodeError, ValidationError) as e:
+ # Parse JSON and validate as StoredToken
+ stored = stored_token_adapter.validate_json(path.read_text())
+
+ # Check if token is expired
+ if stored.expires_at is not None:
+ now = datetime.now(timezone.utc)
+ if now >= stored.expires_at:
+ logger.debug(
+ f"Token expired for {self.get_base_url(self.server_url)}"
+ )
+ return None
+
+ # Recalculate expires_in to be correct relative to now
+ if stored.token_payload.expires_in is not None:
+ remaining = stored.expires_at - now
+ stored.token_payload.expires_in = max(
+ 0, int(remaining.total_seconds())
+ )
+
+ return stored.token_payload
+
+ except (FileNotFoundError, ValidationError) as e:
logger.debug(
f"Could not load tokens for {self.get_base_url(self.server_url)}: {e}"
)
@@ -92,7 +119,18 @@ class FileTokenStorage(TokenStorage):
async def set_tokens(self, tokens: OAuthToken) -> None:
"""Save tokens to file storage."""
path = self._get_file_path("tokens")
- path.write_text(tokens.model_dump_json(indent=2))
+
+ # Calculate absolute expiry time if expires_in is present
+ expires_at = None
+ if tokens.expires_in is not None:
+ expires_at = datetime.now(timezone.utc) + timedelta(
+ seconds=tokens.expires_in
+ )
+
+ # Create StoredToken and save using Pydantic serialization
+ stored = StoredToken(token_payload=tokens, expires_at=expires_at)
+
+ path.write_text(stored.model_dump_json(indent=2))
logger.debug(f"Saved tokens for {self.get_base_url(self.server_url)}")
async def get_client_info(self) -> OAuthClientInformationFull | None:
@@ -252,6 +290,15 @@ class OAuth(OAuthClientProvider):
callback_handler=self.callback_handler,
)
+ async def _initialize(self) -> None:
+ """Load stored tokens and client info, properly setting token expiry."""
+ # Call parent's _initialize to load tokens and client info
+ await super()._initialize()
+
+ # If tokens were loaded and have expires_in, update the context's token_expiry_time
+ if self.context.current_tokens and self.context.current_tokens.expires_in:
+ self.context.update_token_expiry(self.context.current_tokens)
+
async def redirect_handler(self, authorization_url: str) -> None:
"""Open browser for authorization."""
logger.info(f"OAuth authorization URL: {authorization_url}")
diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py
index ec94a1f17..5e031da38 100644
--- a/src/fastmcp/client/transports.py
+++ b/src/fastmcp/client/transports.py
@@ -36,8 +36,8 @@ 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 Environment
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
logger = get_logger(__name__)
@@ -597,7 +597,7 @@ class UvStdioTransport(StdioTransport):
)
# Create Environment from provided parameters (internal use)
- env_config = Environment(
+ env_config = UVEnvironment(
python=python_version,
dependencies=with_packages,
requirements=with_requirements,
diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py
index c2a011041..14a6e9fd0 100644
--- a/src/fastmcp/server/auth/oauth_proxy.py
+++ b/src/fastmcp/server/auth/oauth_proxy.py
@@ -254,45 +254,20 @@ class OAuthProxy(OAuthProvider):
upstream_client_secret: Client secret for upstream server
upstream_revocation_endpoint: Optional upstream revocation endpoint
token_verifier: Token verifier for validating access tokens
- base_url: Public URL of this FastMCP server
+ base_url: Public URL of the server that exposes this FastMCP server; redirect path is
+ relative to this URL
redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback")
issuer_url: Issuer URL for OAuth metadata (defaults to base_url)
service_documentation_url: Optional service documentation URL
- resource_server_url: Resource server URL (defaults to base_url)
+ resource_server_url: Path of the FastMCP server. If None, FastMCP will
+ attempt to overwrite this with the correct path to the server
+ e.g. {base_url}/mcp
allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
Patterns support wildcards (e.g., "http://localhost:*", "https://*.example.com/*").
If None (default), only localhost redirect URIs are allowed.
If empty list, all redirect URIs are allowed (not recommended for production).
These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app.
"""
- # Convert string URLs to AnyHttpUrl for parent class
- base_url_parsed = (
- AnyHttpUrl(base_url) if isinstance(base_url, str) else base_url
- )
- issuer_url_parsed = (
- (AnyHttpUrl(issuer_url) if isinstance(issuer_url, str) else issuer_url)
- if issuer_url
- else None
- )
- service_documentation_url_parsed = (
- (
- AnyHttpUrl(service_documentation_url)
- if isinstance(service_documentation_url, str)
- else service_documentation_url
- )
- if service_documentation_url
- else None
- )
- resource_server_url_parsed = (
- (
- AnyHttpUrl(resource_server_url)
- if isinstance(resource_server_url, str)
- else resource_server_url
- )
- if resource_server_url
- else None
- )
-
# Always enable DCR since we implement it locally for MCP clients
client_registration_options = ClientRegistrationOptions(enabled=True)
@@ -302,13 +277,13 @@ class OAuthProxy(OAuthProvider):
)
super().__init__(
- base_url=base_url_parsed,
- issuer_url=issuer_url_parsed,
- service_documentation_url=service_documentation_url_parsed,
+ base_url=base_url,
+ issuer_url=issuer_url,
+ service_documentation_url=service_documentation_url,
client_registration_options=client_registration_options,
revocation_options=revocation_options,
required_scopes=token_verifier.required_scopes,
- resource_server_url=resource_server_url_parsed,
+ resource_server_url=resource_server_url,
)
# Store upstream configuration
@@ -317,6 +292,7 @@ class OAuthProxy(OAuthProvider):
self._upstream_client_id = upstream_client_id
self._upstream_client_secret = SecretStr(upstream_client_secret)
self._upstream_revocation_endpoint = upstream_revocation_endpoint
+ self._default_scope_str = " ".join(self.required_scopes or [])
# Store redirect configuration
self._redirect_path = (
@@ -376,6 +352,7 @@ class OAuthProxy(OAuthProvider):
AnyUrl("http://localhost")
], # Placeholder, validation uses allowed_patterns
grant_types=["authorization_code", "refresh_token"],
+ scope=self._default_scope_str,
token_endpoint_auth_method="none",
allowed_redirect_uri_patterns=self._allowed_client_redirect_uris,
)
@@ -415,6 +392,7 @@ class OAuthProxy(OAuthProvider):
redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")],
grant_types=client_info.grant_types
or ["authorization_code", "refresh_token"],
+ scope=self._default_scope_str,
token_endpoint_auth_method="none",
allowed_redirect_uri_patterns=self._allowed_client_redirect_uris,
)
@@ -422,6 +400,15 @@ class OAuthProxy(OAuthProvider):
# Store the ProxyDCRClient using the upstream ID
self._clients[upstream_id] = proxy_client
+ # Log redirect URIs to help users discover what patterns they might need
+ if client_info.redirect_uris:
+ for uri in client_info.redirect_uris:
+ logger.debug(
+ "Client registered with redirect_uri: %s - if restricting redirect URIs, "
+ "ensure this pattern is allowed in allowed_client_redirect_uris",
+ uri,
+ )
+
logger.debug(
"Registered client %s with %d redirect URIs",
upstream_id,
diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py
index a4de21021..806dddf18 100644
--- a/src/fastmcp/server/auth/providers/azure.py
+++ b/src/fastmcp/server/auth/providers/azure.py
@@ -36,6 +36,8 @@ class AzureProviderSettings(BaseSettings):
redirect_path: str | None = None
required_scopes: list[str] | None = None
timeout_seconds: int | None = None
+ resource_server_url: str | None = None
+ allowed_client_redirect_uris: list[str] | None = None
@field_validator("required_scopes", mode="before")
@classmethod
@@ -160,6 +162,8 @@ class AzureProvider(OAuthProxy):
redirect_path: str | NotSetT = NotSet,
required_scopes: list[str] | None | NotSetT = NotSet,
timeout_seconds: int | NotSetT = NotSet,
+ resource_server_url: str | NotSetT = NotSet,
+ allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
):
"""Initialize Azure OAuth provider.
@@ -171,6 +175,10 @@ class AzureProvider(OAuthProxy):
redirect_path: Redirect path configured in Azure (defaults to "/auth/callback")
required_scopes: Required scopes (defaults to ["User.Read", "email", "openid", "profile"])
timeout_seconds: HTTP request timeout for Azure API calls
+ resource_server_url: Path of the FastMCP server (defaults to base_url). If your MCP endpoint is at
+ a different path like {base_url}/mcp, specify it here for RFC 8707 compliance.
+ allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
+ If None (default), all URIs are allowed. If empty list, no URIs are allowed.
"""
settings = AzureProviderSettings.model_validate(
{
@@ -183,6 +191,8 @@ class AzureProvider(OAuthProxy):
"redirect_path": redirect_path,
"required_scopes": required_scopes,
"timeout_seconds": timeout_seconds,
+ "resource_server_url": resource_server_url,
+ "allowed_client_redirect_uris": allowed_client_redirect_uris,
}.items()
if v is not NotSet
}
@@ -217,6 +227,8 @@ class AzureProvider(OAuthProxy):
"openid",
"profile",
]
+ resource_server_url_final = settings.resource_server_url or base_url_final
+ allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
# Extract secret string from SecretStr
client_secret_str = (
@@ -247,6 +259,8 @@ class AzureProvider(OAuthProxy):
base_url=base_url_final,
redirect_path=redirect_path_final,
issuer_url=base_url_final,
+ allowed_client_redirect_uris=allowed_client_redirect_uris_final,
+ resource_server_url=resource_server_url_final,
)
logger.info(
diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py
index 4f3fcc862..49613897a 100644
--- a/src/fastmcp/server/auth/providers/github.py
+++ b/src/fastmcp/server/auth/providers/github.py
@@ -51,6 +51,8 @@ class GitHubProviderSettings(BaseSettings):
redirect_path: str | None = None
required_scopes: list[str] | None = None
timeout_seconds: int | None = None
+ resource_server_url: AnyHttpUrl | str | None = None
+ allowed_client_redirect_uris: list[str] | None = None
@field_validator("required_scopes", mode="before")
@classmethod
@@ -199,8 +201,10 @@ class GitHubProvider(OAuthProxy):
client_secret: str | NotSetT = NotSet,
base_url: AnyHttpUrl | str | NotSetT = NotSet,
redirect_path: str | NotSetT = NotSet,
- required_scopes: list[str] | None | NotSetT = NotSet,
+ required_scopes: list[str] | NotSetT = NotSet,
timeout_seconds: int | NotSetT = NotSet,
+ resource_server_url: AnyHttpUrl | str | NotSetT = NotSet,
+ allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
):
"""Initialize GitHub OAuth provider.
@@ -211,6 +215,10 @@ class GitHubProvider(OAuthProxy):
redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback")
required_scopes: Required GitHub scopes (defaults to ["user"])
timeout_seconds: HTTP request timeout for GitHub API calls
+ resource_server_url: Path of the FastMCP server (defaults to base_url). If your MCP endpoint is at
+ a different path like {base_url}/mcp, specify it here for RFC 8707 compliance.
+ allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
+ If None (default), all URIs are allowed. If empty list, no URIs are allowed.
"""
settings = GitHubProviderSettings.model_validate(
{
@@ -222,6 +230,8 @@ class GitHubProvider(OAuthProxy):
"redirect_path": redirect_path,
"required_scopes": required_scopes,
"timeout_seconds": timeout_seconds,
+ "resource_server_url": resource_server_url,
+ "allowed_client_redirect_uris": allowed_client_redirect_uris,
}.items()
if v is not NotSet
}
@@ -242,6 +252,8 @@ class GitHubProvider(OAuthProxy):
redirect_path_final = settings.redirect_path or "/auth/callback"
timeout_seconds_final = settings.timeout_seconds or 10
required_scopes_final = settings.required_scopes or ["user"]
+ resource_server_url_final = settings.resource_server_url or base_url_final
+ allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
# Create GitHub token verifier
token_verifier = GitHubTokenVerifier(
@@ -264,6 +276,8 @@ class GitHubProvider(OAuthProxy):
base_url=base_url_final,
redirect_path=redirect_path_final,
issuer_url=base_url_final, # We act as the issuer for client registration
+ allowed_client_redirect_uris=allowed_client_redirect_uris_final,
+ resource_server_url=resource_server_url_final,
)
logger.info(
diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py
index 920034395..337bf6157 100644
--- a/src/fastmcp/server/auth/providers/google.py
+++ b/src/fastmcp/server/auth/providers/google.py
@@ -53,6 +53,8 @@ class GoogleProviderSettings(BaseSettings):
redirect_path: str | None = None
required_scopes: list[str] | None = None
timeout_seconds: int | None = None
+ resource_server_url: AnyHttpUrl | str | None = None
+ allowed_client_redirect_uris: list[str] | None = None
@field_validator("required_scopes", mode="before")
@classmethod
@@ -215,8 +217,10 @@ class GoogleProvider(OAuthProxy):
client_secret: str | NotSetT = NotSet,
base_url: AnyHttpUrl | str | NotSetT = NotSet,
redirect_path: str | NotSetT = NotSet,
- required_scopes: list[str] | None | NotSetT = NotSet,
+ required_scopes: list[str] | NotSetT = NotSet,
timeout_seconds: int | NotSetT = NotSet,
+ resource_server_url: AnyHttpUrl | str | NotSetT = NotSet,
+ allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
):
"""Initialize Google OAuth provider.
@@ -230,6 +234,8 @@ class GoogleProvider(OAuthProxy):
- "https://www.googleapis.com/auth/userinfo.email" for email access
- "https://www.googleapis.com/auth/userinfo.profile" for profile info
timeout_seconds: HTTP request timeout for Google API calls
+ allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
+ If None (default), all URIs are allowed. If empty list, no URIs are allowed.
"""
settings = GoogleProviderSettings.model_validate(
{
@@ -241,6 +247,8 @@ class GoogleProvider(OAuthProxy):
"redirect_path": redirect_path,
"required_scopes": required_scopes,
"timeout_seconds": timeout_seconds,
+ "resource_server_url": resource_server_url,
+ "allowed_client_redirect_uris": allowed_client_redirect_uris,
}.items()
if v is not NotSet
}
@@ -262,6 +270,8 @@ class GoogleProvider(OAuthProxy):
timeout_seconds_final = settings.timeout_seconds or 10
# Google requires at least one scope - openid is the minimal OIDC scope
required_scopes_final = settings.required_scopes or ["openid"]
+ resource_server_url_final = settings.resource_server_url or base_url_final
+ allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
# Create Google token verifier
token_verifier = GoogleTokenVerifier(
@@ -284,6 +294,8 @@ class GoogleProvider(OAuthProxy):
base_url=base_url_final,
redirect_path=redirect_path_final,
issuer_url=base_url_final, # We act as the issuer for client registration
+ allowed_client_redirect_uris=allowed_client_redirect_uris_final,
+ resource_server_url=resource_server_url_final,
)
logger.info(
diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py
index 2fc138141..2c443eb64 100644
--- a/src/fastmcp/server/auth/providers/workos.py
+++ b/src/fastmcp/server/auth/providers/workos.py
@@ -43,6 +43,8 @@ class WorkOSProviderSettings(BaseSettings):
redirect_path: str | None = None
required_scopes: list[str] | None = None
timeout_seconds: int | None = None
+ resource_server_url: AnyHttpUrl | str | None = None
+ allowed_client_redirect_uris: list[str] | None = None
@field_validator("required_scopes", mode="before")
@classmethod
@@ -167,6 +169,8 @@ class WorkOSProvider(OAuthProxy):
redirect_path: str | NotSetT = NotSet,
required_scopes: list[str] | None | NotSetT = NotSet,
timeout_seconds: int | NotSetT = NotSet,
+ resource_server_url: AnyHttpUrl | str | NotSetT = NotSet,
+ allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
):
"""Initialize WorkOS OAuth provider.
@@ -178,6 +182,10 @@ class WorkOSProvider(OAuthProxy):
redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback")
required_scopes: Required OAuth scopes (no default)
timeout_seconds: HTTP request timeout for WorkOS API calls
+ resource_server_url: Path of the FastMCP server (defaults to base_url). If your MCP endpoint is at
+ a different path like {base_url}/mcp, specify it here for RFC 8707 compliance.
+ allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
+ If None (default), all URIs are allowed. If empty list, no URIs are allowed.
"""
settings = WorkOSProviderSettings.model_validate(
{
@@ -190,6 +198,8 @@ class WorkOSProvider(OAuthProxy):
"redirect_path": redirect_path,
"required_scopes": required_scopes,
"timeout_seconds": timeout_seconds,
+ "resource_server_url": resource_server_url,
+ "allowed_client_redirect_uris": allowed_client_redirect_uris,
}.items()
if v is not NotSet
}
@@ -218,6 +228,8 @@ class WorkOSProvider(OAuthProxy):
redirect_path_final = settings.redirect_path or "/auth/callback"
timeout_seconds_final = settings.timeout_seconds or 10
scopes_final = settings.required_scopes or []
+ resource_server_url_final = settings.resource_server_url or base_url_final
+ allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
# Extract secret string from SecretStr
client_secret_str = (
@@ -241,6 +253,8 @@ class WorkOSProvider(OAuthProxy):
base_url=base_url_final,
redirect_path=redirect_path_final,
issuer_url=base_url_final,
+ allowed_client_redirect_uris=allowed_client_redirect_uris_final,
+ resource_server_url=resource_server_url_final,
)
logger.info(
diff --git a/src/fastmcp/server/auth/redirect_validation.py b/src/fastmcp/server/auth/redirect_validation.py
index 597c7d23f..f49958ad5 100644
--- a/src/fastmcp/server/auth/redirect_validation.py
+++ b/src/fastmcp/server/auth/redirect_validation.py
@@ -33,8 +33,9 @@ def validate_redirect_uri(
Args:
redirect_uri: The redirect URI to validate
- allowed_patterns: List of allowed patterns. If None, defaults to localhost.
- If empty list, all URIs are allowed.
+ allowed_patterns: List of allowed patterns. If None, all URIs are allowed (for DCR compatibility).
+ If empty list, no URIs are allowed.
+ To restrict to localhost only, explicitly pass DEFAULT_LOCALHOST_PATTERNS.
Returns:
True if the redirect URI is allowed
@@ -44,15 +45,9 @@ def validate_redirect_uri(
uri_str = str(redirect_uri)
- # If no patterns specified, default to localhost only
+ # If no patterns specified, allow all for DCR compatibility
+ # (clients need to dynamically register with their own redirect URIs)
if allowed_patterns is None:
- allowed_patterns = [
- "http://localhost:*",
- "http://127.0.0.1:*",
- ]
-
- # Empty list means allow all
- if len(allowed_patterns) == 0:
return True
# Check if URI matches any allowed pattern
diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py
index f770e2faa..3511a3388 100644
--- a/src/fastmcp/server/middleware/logging.py
+++ b/src/fastmcp/server/middleware/logging.py
@@ -2,11 +2,20 @@
import json
import logging
+from collections.abc import Callable
+from logging import Logger
from typing import Any
+import pydantic_core
+
from .middleware import CallNext, Middleware, MiddlewareContext
+def default_serializer(data: Any) -> str:
+ """The default serializer for Payloads in the logging middleware."""
+ return pydantic_core.to_json(data, fallback=str).decode()
+
+
class LoggingMiddleware(Middleware):
"""Middleware that provides comprehensive request and response logging.
@@ -33,6 +42,7 @@ class LoggingMiddleware(Middleware):
include_payloads: bool = False,
max_payload_length: int = 1000,
methods: list[str] | None = None,
+ payload_serializer: Callable[[Any], str] | None = None,
):
"""Initialize logging middleware.
@@ -43,13 +53,14 @@ class LoggingMiddleware(Middleware):
max_payload_length: Maximum length of payload to log (prevents huge logs)
methods: List of methods to log. If None, logs all methods.
"""
- self.logger = logger or logging.getLogger("fastmcp.requests")
- self.log_level = log_level
- self.include_payloads = include_payloads
- self.max_payload_length = max_payload_length
- self.methods = methods
+ self.logger: Logger = logger or logging.getLogger("fastmcp.requests")
+ self.log_level: int = log_level
+ self.include_payloads: bool = include_payloads
+ self.max_payload_length: int = max_payload_length
+ self.methods: list[str] | None = methods
+ self.payload_serializer: Callable[[Any], str] | None = payload_serializer
- def _format_message(self, context: MiddlewareContext) -> str:
+ def _format_message(self, context: MiddlewareContext[Any]) -> str:
"""Format a message for logging."""
parts = [
f"source={context.source}",
@@ -57,18 +68,29 @@ class LoggingMiddleware(Middleware):
f"method={context.method or 'unknown'}",
]
- if self.include_payloads and hasattr(context.message, "__dict__"):
- try:
- payload = json.dumps(context.message.__dict__, default=str)
- if len(payload) > self.max_payload_length:
- payload = payload[: self.max_payload_length] + "..."
- parts.append(f"payload={payload}")
- except (TypeError, ValueError):
- parts.append("payload=")
+ if self.include_payloads:
+ payload: str
+ if not self.payload_serializer:
+ payload = default_serializer(context.message)
+ else:
+ try:
+ payload = self.payload_serializer(context.message)
+ except Exception as e:
+ self.logger.warning(
+ f"Failed {e} to serialize payload: {context.type} {context.method} {context.source}."
+ )
+ payload = default_serializer(context.message)
+
+ if len(payload) > self.max_payload_length:
+ payload = payload[: self.max_payload_length] + "..."
+
+ parts.append(f"payload={payload}")
return " ".join(parts)
- async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
+ async def on_message(
+ self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
+ ) -> Any:
"""Log all messages."""
message_info = self._format_message(context)
if self.methods and context.method not in self.methods:
@@ -111,6 +133,7 @@ class StructuredLoggingMiddleware(Middleware):
log_level: int = logging.INFO,
include_payloads: bool = False,
methods: list[str] | None = None,
+ payload_serializer: Callable[[Any], str] | None = None,
):
"""Initialize structured logging middleware.
@@ -119,15 +142,18 @@ class StructuredLoggingMiddleware(Middleware):
log_level: Log level for messages (default: INFO)
include_payloads: Whether to include message payloads in logs
methods: List of methods to log. If None, logs all methods.
+ serializer: Callable that converts objects to a JSON string for the
+ payload. If not provided, uses FastMCP's default tool serializer.
"""
- self.logger = logger or logging.getLogger("fastmcp.structured")
- self.log_level = log_level
- self.include_payloads = include_payloads
- self.methods = methods
+ self.logger: Logger = logger or logging.getLogger("fastmcp.structured")
+ self.log_level: int = log_level
+ self.include_payloads: bool = include_payloads
+ self.methods: list[str] | None = methods
+ self.payload_serializer: Callable[[Any], str] | None = payload_serializer
def _create_log_entry(
- self, context: MiddlewareContext, event: str, **extra_fields
- ) -> dict:
+ self, context: MiddlewareContext[Any], event: str, **extra_fields: Any
+ ) -> dict[str, Any]:
"""Create a structured log entry."""
entry = {
"event": event,
@@ -138,15 +164,27 @@ class StructuredLoggingMiddleware(Middleware):
**extra_fields,
}
- if self.include_payloads and hasattr(context.message, "__dict__"):
- try:
- entry["payload"] = context.message.__dict__
- except (TypeError, ValueError):
- entry["payload"] = ""
+ if self.include_payloads:
+ payload: str
+
+ if not self.payload_serializer:
+ payload = default_serializer(context.message)
+ else:
+ try:
+ payload = self.payload_serializer(context.message)
+ except Exception as e:
+ self.logger.warning(
+ f"Failed {str(e)} to serialize payload: {context.type} {context.method} {context.source}."
+ )
+ payload = default_serializer(context.message)
+
+ entry["payload"] = payload
return entry
- async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:
+ async def on_message(
+ self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]
+ ) -> Any:
"""Log structured message information."""
start_entry = self._create_log_entry(context, "request_start")
if self.methods and context.method not in self.methods:
diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py
index 1c208da4d..2ae540d89 100644
--- a/src/fastmcp/utilities/cli.py
+++ b/src/fastmcp/utilities/cli.py
@@ -1,8 +1,12 @@
from __future__ import annotations
+import json
+import os
from importlib.metadata import version
+from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
+from pydantic import ValidationError
from rich.align import Align
from rich.console import Console, Group
from rich.panel import Panel
@@ -10,10 +14,130 @@ from rich.table import Table
from rich.text import Text
import fastmcp
+from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.mcp_server_config import MCPServerConfig
+from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
+from fastmcp.utilities.types import get_cached_typeadapter
if TYPE_CHECKING:
from fastmcp import FastMCP
+logger = get_logger("cli.config")
+
+
+def is_already_in_uv_subprocess() -> bool:
+ """Check if we're already running in a FastMCP uv subprocess."""
+ return bool(os.environ.get("FASTMCP_UV_SPAWNED"))
+
+
+def load_and_merge_config(
+ server_spec: str | None,
+ **cli_overrides,
+) -> tuple[MCPServerConfig, str]:
+ """Load config from server_spec and apply CLI overrides.
+
+ This consolidates the config parsing logic that was duplicated across
+ run, inspect, and dev commands.
+
+ Args:
+ server_spec: Python file, config file, URL, or None to auto-detect
+ cli_overrides: CLI arguments that override config values
+
+ Returns:
+ Tuple of (MCPServerConfig, resolved_server_spec)
+ """
+ config = None
+ config_path = None
+
+ # Auto-detect fastmcp.json if no server_spec provided
+ if server_spec is None:
+ config_path = Path("fastmcp.json")
+ if not config_path.exists():
+ found_config = MCPServerConfig.find_config()
+ if found_config:
+ config_path = found_config
+ else:
+ logger.error(
+ "No server specification provided and no fastmcp.json found in current directory.\n"
+ "Please specify a server file or create a fastmcp.json configuration."
+ )
+ raise FileNotFoundError("No server specification or fastmcp.json found")
+
+ resolved_spec = str(config_path)
+ logger.info(f"Using configuration from {config_path}")
+ else:
+ resolved_spec = server_spec
+
+ # Load config if server_spec is a .json file
+ if resolved_spec.endswith(".json"):
+ config_path = Path(resolved_spec)
+ if config_path.exists():
+ try:
+ 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:
+ # MCPConfig - we don't process these here, just pass through
+ pass
+ else:
+ # Try to parse as MCPServerConfig
+ try:
+ adapter = get_cached_typeadapter(MCPServerConfig)
+ config = adapter.validate_python(data)
+
+ # Apply deployment settings
+ if config.deployment:
+ config.deployment.apply_runtime_settings(config_path)
+
+ except ValidationError:
+ # Not a valid MCPServerConfig, just pass through
+ pass
+ except (json.JSONDecodeError, FileNotFoundError):
+ # Not a valid JSON file, just pass through
+ pass
+
+ # If we don't have a config object yet, create one from filesystem source
+ if config is None:
+ source = FileSystemSource(path=resolved_spec)
+ config = MCPServerConfig(source=source)
+
+ # Convert to dict for immutable transformation
+ config_dict = config.model_dump()
+
+ # Apply CLI overrides to config's environment (always exists due to default_factory)
+ if python_override := cli_overrides.get("python"):
+ config_dict["environment"]["python"] = python_override
+ if packages_override := cli_overrides.get("with_packages"):
+ # Merge packages - CLI packages are added to config packages
+ existing = config_dict["environment"].get("dependencies") or []
+ config_dict["environment"]["dependencies"] = packages_override + existing
+ if requirements_override := cli_overrides.get("with_requirements"):
+ config_dict["environment"]["requirements"] = str(requirements_override)
+ if project_override := cli_overrides.get("project"):
+ config_dict["environment"]["project"] = str(project_override)
+ if editable_override := cli_overrides.get("editable"):
+ config_dict["environment"]["editable"] = editable_override
+
+ # Apply deployment CLI overrides (always exists due to default_factory)
+ if transport_override := cli_overrides.get("transport"):
+ config_dict["deployment"]["transport"] = transport_override
+ if host_override := cli_overrides.get("host"):
+ config_dict["deployment"]["host"] = host_override
+ if port_override := cli_overrides.get("port"):
+ config_dict["deployment"]["port"] = port_override
+ if path_override := cli_overrides.get("path"):
+ config_dict["deployment"]["path"] = path_override
+ if log_level_override := cli_overrides.get("log_level"):
+ config_dict["deployment"]["log_level"] = log_level_override
+ if server_args_override := cli_overrides.get("server_args"):
+ config_dict["deployment"]["args"] = server_args_override
+
+ # Create new config from modified dict
+ new_config = MCPServerConfig(**config_dict)
+ return new_config, resolved_spec
+
+
LOGO_ASCII = r"""
_ __ ___ _____ __ __ _____________ ____ ____
_ __ ___ .'____/___ ______/ /_/ |/ / ____/ __ \ |___ \ / __ \
diff --git a/src/fastmcp/utilities/fastmcp_config/__init__.py b/src/fastmcp/utilities/fastmcp_config/__init__.py
deleted file mode 100644
index 28236fe42..000000000
--- a/src/fastmcp/utilities/fastmcp_config/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""FastMCP Configuration module.
-
-This module provides versioned configuration support for FastMCP servers.
-The current version is v1, which is re-exported here for convenience.
-"""
-
-from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import (
- Deployment,
- Environment,
- FastMCPConfig,
- generate_schema,
-)
-from fastmcp.utilities.fastmcp_config.v1.sources.base import BaseSource
-from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
-
-__all__ = [
- "BaseSource",
- "Deployment",
- "Environment",
- "FastMCPConfig",
- "FileSystemSource",
- "generate_schema",
-]
diff --git a/src/fastmcp/utilities/mcp_server_config/__init__.py b/src/fastmcp/utilities/mcp_server_config/__init__.py
new file mode 100644
index 000000000..cbbfe5aa3
--- /dev/null
+++ b/src/fastmcp/utilities/mcp_server_config/__init__.py
@@ -0,0 +1,25 @@
+"""FastMCP Configuration module.
+
+This module provides versioned configuration support for FastMCP servers.
+The current version is v1, which is re-exported here for convenience.
+"""
+
+from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
+from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import (
+ Deployment,
+ MCPServerConfig,
+ generate_schema,
+)
+from fastmcp.utilities.mcp_server_config.v1.sources.base import Source
+from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
+
+__all__ = [
+ "Source",
+ "Deployment",
+ "Environment",
+ "UVEnvironment",
+ "MCPServerConfig",
+ "FileSystemSource",
+ "generate_schema",
+]
diff --git a/src/fastmcp/utilities/fastmcp_config/v1/__init__.py b/src/fastmcp/utilities/mcp_server_config/v1/__init__.py
similarity index 100%
rename from src/fastmcp/utilities/fastmcp_config/v1/__init__.py
rename to src/fastmcp/utilities/mcp_server_config/v1/__init__.py
diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/__init__.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/__init__.py
new file mode 100644
index 000000000..3cccf548f
--- /dev/null
+++ b/src/fastmcp/utilities/mcp_server_config/v1/environments/__init__.py
@@ -0,0 +1,6 @@
+"""Environment configuration for MCP servers."""
+
+from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
+
+__all__ = ["Environment", "UVEnvironment"]
diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py
new file mode 100644
index 000000000..8209c7f4f
--- /dev/null
+++ b/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py
@@ -0,0 +1,30 @@
+from abc import ABC, abstractmethod
+from pathlib import Path
+
+from pydantic import BaseModel, Field
+
+
+class Environment(BaseModel, ABC):
+ """Base class for environment configuration."""
+
+ type: str = Field(description="Environment type identifier")
+
+ @abstractmethod
+ def build_command(self, command: list[str]) -> list[str]:
+ """Build the full command with environment setup.
+
+ Args:
+ command: Base command to wrap with environment setup
+
+ Returns:
+ Full command ready for subprocess execution
+ """
+ pass
+
+ async def prepare(self, output_dir: Path | None = None) -> None:
+ """Prepare the environment (optional, can be no-op).
+
+ Args:
+ output_dir: Directory for persistent environment setup
+ """
+ pass # Default no-op implementation
diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py
new file mode 100644
index 000000000..3531dc569
--- /dev/null
+++ b/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py
@@ -0,0 +1,306 @@
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from typing import Literal
+
+from pydantic import Field
+
+from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment
+
+logger = get_logger("cli.config")
+
+
+class UVEnvironment(Environment):
+ """Configuration for Python environment setup."""
+
+ type: Literal["uv"] = "uv"
+
+ python: str | None = Field(
+ default=None,
+ description="Python version constraint",
+ examples=["3.10", "3.11", "3.12"],
+ )
+
+ dependencies: list[str] | None = Field(
+ default=None,
+ description="Python packages to install with PEP 508 specifiers",
+ examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]],
+ )
+
+ requirements: str | None = Field(
+ default=None,
+ description="Path to requirements.txt file",
+ examples=["requirements.txt", "../requirements/prod.txt"],
+ )
+
+ project: str | None = Field(
+ default=None,
+ description="Path to project directory containing pyproject.toml",
+ examples=[".", "../my-project"],
+ )
+
+ editable: list[str] | None = Field(
+ default=None,
+ description="Directories to install in editable mode",
+ examples=[[".", "../my-package"], ["/path/to/package"]],
+ )
+
+ def build_command(self, command: list[str]) -> list[str]:
+ """Build complete uv run command with environment args and command to execute.
+
+ Args:
+ command: Command to execute (e.g., ["fastmcp", "run", "server.py"])
+
+ Returns:
+ Complete command ready for subprocess.run, including "uv" prefix if needed.
+ If no environment configuration is set, returns the command unchanged.
+ """
+ # If no environment setup is needed, return command as-is
+ if not self._needs_setup():
+ return command
+
+ args = ["uv", "run"]
+
+ # Add project if specified
+ if self.project:
+ args.extend(["--project", str(self.project)])
+
+ # Add Python version if specified (only if no project, as project has its own Python)
+ if self.python and not self.project:
+ args.extend(["--python", self.python])
+
+ # Always add dependencies, requirements, and editable packages
+ # These work with --project to add additional packages on top of the project env
+ if self.dependencies:
+ for dep in self.dependencies:
+ args.extend(["--with", dep])
+
+ # Add requirements file
+ if self.requirements:
+ args.extend(["--with-requirements", str(self.requirements)])
+
+ # Add editable packages
+ if self.editable:
+ for editable_path in self.editable:
+ args.extend(["--with-editable", str(editable_path)])
+
+ # Add the command
+ args.extend(command)
+
+ return args
+
+ def run_with_uv(self, command: list[str]) -> None:
+ """Execute a command using uv run with this environment configuration.
+
+ Args:
+ command: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"])
+ """
+ import subprocess
+
+ # Build the full uv command
+ cmd = self.build_command(command)
+
+ # Set marker to prevent infinite loops when subprocess calls FastMCP again
+ env = os.environ | {"FASTMCP_UV_SPAWNED": "1"}
+
+ logger.debug(f"Running command: {' '.join(cmd)}")
+
+ try:
+ # Run without capturing output so it flows through naturally
+ process = subprocess.run(cmd, check=True, env=env)
+ sys.exit(process.returncode)
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Command failed: {e}")
+ sys.exit(e.returncode)
+
+ def _needs_setup(self) -> bool:
+ """Check if this environment config requires uv to set up.
+
+ Returns:
+ True if any environment settings require uv run
+ """
+ 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,
+ ]
+ )
+
+ # Backward compatibility aliases
+ def needs_uv(self) -> bool:
+ """Deprecated: Use _needs_setup() internally or check if build_command modifies the command."""
+ return self._needs_setup()
+
+ def build_uv_run_command(self, command: list[str]) -> list[str]:
+ """Deprecated: Use build_command() instead."""
+ return self.build_command(command)
+
+ async def prepare(self, output_dir: Path | None = None) -> None:
+ """Prepare the Python environment using uv.
+
+ Args:
+ output_dir: Directory where the persistent uv project will be created.
+ If None, creates a temporary directory for ephemeral use.
+ """
+
+ # Check if uv is available
+ if not shutil.which("uv"):
+ raise RuntimeError(
+ "uv is not installed. Please install it with: "
+ "curl -LsSf https://astral.sh/uv/install.sh | sh"
+ )
+
+ # Only prepare environment if there are actual settings to apply
+ if not self._needs_setup():
+ logger.debug("No environment settings configured, skipping preparation")
+ return
+
+ # Handle None case for ephemeral use
+ if output_dir is None:
+ import tempfile
+
+ output_dir = Path(tempfile.mkdtemp(prefix="fastmcp-env-"))
+ logger.info(f"Creating ephemeral environment in {output_dir}")
+ else:
+ logger.info(f"Creating persistent environment in {output_dir}")
+ output_dir = Path(output_dir).resolve()
+
+ # Initialize the project
+ logger.debug(f"Initializing uv project in {output_dir}")
+ try:
+ subprocess.run(
+ [
+ "uv",
+ "init",
+ "--project",
+ str(output_dir),
+ "--name",
+ "fastmcp-env",
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.CalledProcessError as e:
+ # If project already exists, that's fine - continue
+ if "already initialized" in e.stderr.lower():
+ logger.debug(
+ f"Project already initialized at {output_dir}, continuing..."
+ )
+ else:
+ logger.error(f"Failed to initialize project: {e.stderr}")
+ raise RuntimeError(f"Failed to initialize project: {e.stderr}") from e
+
+ # Pin Python version if specified
+ if self.python:
+ logger.debug(f"Pinning Python version to {self.python}")
+ try:
+ subprocess.run(
+ [
+ "uv",
+ "python",
+ "pin",
+ self.python,
+ "--project",
+ str(output_dir),
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Failed to pin Python version: {e.stderr}")
+ raise RuntimeError(f"Failed to pin Python version: {e.stderr}") from e
+
+ # Add dependencies with --no-sync to defer installation
+ # dependencies ALWAYS include fastmcp; this is compatible with
+ # specific fastmcp versions that might be in the dependencies list
+ dependencies = (self.dependencies or []) + ["fastmcp"]
+ logger.debug(f"Adding dependencies: {', '.join(dependencies)}")
+ try:
+ subprocess.run(
+ [
+ "uv",
+ "add",
+ *dependencies,
+ "--no-sync",
+ "--project",
+ str(output_dir),
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Failed to add dependencies: {e.stderr}")
+ raise RuntimeError(f"Failed to add dependencies: {e.stderr}") from e
+
+ # Add requirements file if specified
+ if self.requirements:
+ logger.debug(f"Adding requirements from {self.requirements}")
+ # Resolve requirements path relative to current directory
+ req_path = Path(self.requirements).resolve()
+ try:
+ subprocess.run(
+ [
+ "uv",
+ "add",
+ "-r",
+ str(req_path),
+ "--no-sync",
+ "--project",
+ str(output_dir),
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Failed to add requirements: {e.stderr}")
+ raise RuntimeError(f"Failed to add requirements: {e.stderr}") from e
+
+ # Add editable packages if specified
+ if self.editable:
+ editable_paths = [str(Path(e).resolve()) for e in self.editable]
+ logger.debug(f"Adding editable packages: {', '.join(editable_paths)}")
+ try:
+ subprocess.run(
+ [
+ "uv",
+ "add",
+ "--editable",
+ *editable_paths,
+ "--no-sync",
+ "--project",
+ str(output_dir),
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Failed to add editable packages: {e.stderr}")
+ raise RuntimeError(
+ f"Failed to add editable packages: {e.stderr}"
+ ) from e
+
+ # Final sync to install everything
+ logger.info("Installing dependencies...")
+ try:
+ subprocess.run(
+ ["uv", "sync", "--project", str(output_dir)],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Failed to sync dependencies: {e.stderr}")
+ raise RuntimeError(f"Failed to sync dependencies: {e.stderr}") from e
+
+ logger.info(f"Environment prepared successfully in {output_dir}")
diff --git a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py
similarity index 53%
rename from src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py
rename to src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py
index 4fb197cbc..4c871cb11 100644
--- a/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py
+++ b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py
@@ -10,15 +10,15 @@ from __future__ import annotations
import json
import os
import re
-import shutil
-import subprocess
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Literal, overload
+from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast, overload
from pydantic import BaseModel, Field, field_validator
-from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
+from fastmcp.utilities.mcp_server_config.v1.sources.base import Source
+from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
logger = get_logger("cli.config")
@@ -27,287 +27,10 @@ FASTMCP_JSON_SCHEMA = "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json
# Type alias for source union (will expand with GitSource, etc in future)
-SourceType = FileSystemSource
+SourceType: TypeAlias = FileSystemSource
-
-class Environment(BaseModel):
- """Configuration for Python environment setup."""
-
- python: str | None = Field(
- default=None,
- description="Python version constraint",
- examples=["3.10", "3.11", "3.12"],
- )
-
- dependencies: list[str] | None = Field(
- default=None,
- description="Python packages to install with PEP 508 specifiers",
- examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]],
- )
-
- requirements: str | None = Field(
- default=None,
- description="Path to requirements.txt file",
- examples=["requirements.txt", "../requirements/prod.txt"],
- )
-
- project: str | None = Field(
- default=None,
- description="Path to project directory containing pyproject.toml",
- examples=[".", "../my-project"],
- )
-
- editable: list[str] | None = Field(
- default=None,
- description="Directories to install in editable mode",
- examples=[[".", "../my-package"], ["/path/to/package"]],
- )
-
- def build_uv_args(self, command: str | list[str] | None = None) -> list[str]:
- """Build uv run arguments from this environment configuration.
-
- Args:
- command: Optional command to append (string or list of args)
-
- Returns:
- List of arguments for uv run command
- """
- args = ["run"]
-
- # Add project if specified
- if self.project:
- args.extend(["--project", str(self.project)])
-
- # Add Python version if specified (only if no project, as project has its own Python)
- if self.python and not self.project:
- args.extend(["--python", self.python])
-
- # Always add dependencies, requirements, and editable packages
- # These work with --project to add additional packages on top of the project env
- if self.dependencies:
- for dep in self.dependencies:
- args.extend(["--with", dep])
-
- # Add requirements file
- if self.requirements:
- args.extend(["--with-requirements", str(self.requirements)])
-
- # Add editable packages
- if self.editable:
- for editable_path in self.editable:
- args.extend(["--with-editable", str(editable_path)])
-
- # Add the command if provided
- if command:
- if isinstance(command, str):
- args.append(command)
- else:
- args.extend(command)
-
- return args
-
- def run_with_uv(self, command: list[str]) -> None:
- """Execute a command using uv run with this environment configuration.
-
- Args:
- command: Command and arguments to execute (e.g., ["fastmcp", "run", "server.py"])
- """
- import subprocess
- import sys
-
- # Build the full uv command
- uv_args = self.build_uv_args(command)
- cmd = ["uv"] + uv_args
-
- logger.debug(f"Running command: {' '.join(cmd)}")
-
- try:
- # Run without capturing output so it flows through naturally
- process = subprocess.run(cmd, check=True)
- sys.exit(process.returncode)
- except subprocess.CalledProcessError as e:
- logger.error(f"Command failed: {e}")
- sys.exit(e.returncode)
-
- def needs_uv(self) -> bool:
- """Check if this environment config requires uv to set up.
-
- Returns:
- True if any environment settings require uv run
- """
- 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,
- ]
- )
-
- async def prepare(self, output_dir: Path | None = None) -> None:
- """Prepare the Python environment using uv.
-
- Args:
- output_dir: Directory where the persistent uv project will be created.
- If None, creates a temporary directory for ephemeral use.
- """
-
- # Check if uv is available
- if not shutil.which("uv"):
- raise RuntimeError(
- "uv is not installed. Please install it with: "
- "curl -LsSf https://astral.sh/uv/install.sh | sh"
- )
-
- # Only prepare environment if there are actual settings to apply
- if not self.needs_uv():
- logger.debug("No environment settings configured, skipping preparation")
- return
-
- # Handle None case for ephemeral use
- if output_dir is None:
- import tempfile
-
- output_dir = Path(tempfile.mkdtemp(prefix="fastmcp-env-"))
- logger.info(f"Creating ephemeral environment in {output_dir}")
- else:
- logger.info(f"Creating persistent environment in {output_dir}")
- output_dir = Path(output_dir).resolve()
-
- # Initialize the project
- logger.debug(f"Initializing uv project in {output_dir}")
- try:
- subprocess.run(
- [
- "uv",
- "init",
- "--project",
- str(output_dir),
- "--name",
- "fastmcp-env",
- ],
- check=True,
- capture_output=True,
- text=True,
- )
- except subprocess.CalledProcessError as e:
- # If project already exists, that's fine - continue
- if "already initialized" in e.stderr.lower():
- logger.debug(
- f"Project already initialized at {output_dir}, continuing..."
- )
- else:
- logger.error(f"Failed to initialize project: {e.stderr}")
- raise RuntimeError(f"Failed to initialize project: {e.stderr}") from e
-
- # Pin Python version if specified
- if self.python:
- logger.debug(f"Pinning Python version to {self.python}")
- try:
- subprocess.run(
- [
- "uv",
- "python",
- "pin",
- self.python,
- "--project",
- str(output_dir),
- ],
- check=True,
- capture_output=True,
- text=True,
- )
- except subprocess.CalledProcessError as e:
- logger.error(f"Failed to pin Python version: {e.stderr}")
- raise RuntimeError(f"Failed to pin Python version: {e.stderr}") from e
-
- # Add dependencies with --no-sync to defer installation
- # dependencies ALWAYS include fastmcp; this is compatible with
- # specific fastmcp versions that might be in the dependencies list
- dependencies = (self.dependencies or []) + ["fastmcp"]
- logger.debug(f"Adding dependencies: {', '.join(dependencies)}")
- try:
- subprocess.run(
- [
- "uv",
- "add",
- *dependencies,
- "--no-sync",
- "--project",
- str(output_dir),
- ],
- check=True,
- capture_output=True,
- text=True,
- )
- except subprocess.CalledProcessError as e:
- logger.error(f"Failed to add dependencies: {e.stderr}")
- raise RuntimeError(f"Failed to add dependencies: {e.stderr}") from e
-
- # Add requirements file if specified
- if self.requirements:
- logger.debug(f"Adding requirements from {self.requirements}")
- # Resolve requirements path relative to current directory
- req_path = Path(self.requirements).resolve()
- try:
- subprocess.run(
- [
- "uv",
- "add",
- "-r",
- str(req_path),
- "--no-sync",
- "--project",
- str(output_dir),
- ],
- check=True,
- capture_output=True,
- text=True,
- )
- except subprocess.CalledProcessError as e:
- logger.error(f"Failed to add requirements: {e.stderr}")
- raise RuntimeError(f"Failed to add requirements: {e.stderr}") from e
-
- # Add editable packages if specified
- if self.editable:
- editable_paths = [str(Path(e).resolve()) for e in self.editable]
- logger.debug(f"Adding editable packages: {', '.join(editable_paths)}")
- try:
- subprocess.run(
- [
- "uv",
- "add",
- "--editable",
- *editable_paths,
- "--no-sync",
- "--project",
- str(output_dir),
- ],
- check=True,
- capture_output=True,
- text=True,
- )
- except subprocess.CalledProcessError as e:
- logger.error(f"Failed to add editable packages: {e.stderr}")
- raise RuntimeError(
- f"Failed to add editable packages: {e.stderr}"
- ) from e
-
- # Final sync to install everything
- logger.info("Installing dependencies...")
- try:
- subprocess.run(
- ["uv", "sync", "--project", str(output_dir)],
- check=True,
- capture_output=True,
- text=True,
- )
- except subprocess.CalledProcessError as e:
- logger.error(f"Failed to sync dependencies: {e.stderr}")
- raise RuntimeError(f"Failed to sync dependencies: {e.stderr}") from e
-
- logger.info(f"Environment prepared successfully in {output_dir}")
+# Type alias for environment union (will expand with other environments in future)
+EnvironmentType: TypeAlias = UVEnvironment
class Deployment(BaseModel):
@@ -408,7 +131,7 @@ class Deployment(BaseModel):
return re.sub(r"\$\{([^}]+)\}", replace_var, value)
-class FastMCPConfig(BaseModel):
+class MCPServerConfig(BaseModel):
"""Configuration for a FastMCP server.
This configuration file allows you to specify all settings needed to run
@@ -433,8 +156,8 @@ class FastMCPConfig(BaseModel):
)
# Environment configuration
- environment: Environment = Field(
- default_factory=lambda: Environment(),
+ environment: EnvironmentType = Field(
+ default_factory=lambda: UVEnvironment(),
description="Python environment setup configuration",
)
@@ -450,14 +173,14 @@ class FastMCPConfig(BaseModel):
@overload
def __init__(self, *, source: dict | FileSystemSource, **data) -> None: ...
@overload
- def __init__(self, *, environment: dict | Environment, **data) -> None: ...
+ def __init__(self, *, environment: dict | UVEnvironment, **data) -> None: ...
@overload
def __init__(self, *, deployment: dict | Deployment, **data) -> None: ...
def __init__(self, **data) -> None: ...
@field_validator("source", mode="before")
@classmethod
- def validate_source(cls, v: dict | FileSystemSource) -> FileSystemSource:
+ def validate_source(cls, v: dict | Source) -> SourceType:
"""Validate and convert source to proper format.
Supports:
@@ -465,34 +188,22 @@ class FastMCPConfig(BaseModel):
- FileSystemSource instance (passed through)
No string parsing happens here - that's only at CLI boundaries.
- FastMCPConfig works only with properly typed objects.
+ MCPServerConfig works only with properly typed objects.
"""
- if isinstance(v, FileSystemSource):
- # Already a FileSystemSource instance, return as-is
- return v
- elif isinstance(v, dict):
- # Dict can have type field or not (filesystem is default)
- if "type" not in v:
- v["type"] = "filesystem"
+ if isinstance(v, dict):
return FileSystemSource(**v)
- else:
- raise ValueError("source must be a dict or FileSystemSource instance")
+ return v
@field_validator("environment", mode="before")
@classmethod
- def validate_environment(cls, v: dict | Environment) -> Environment:
- """Validate and convert environment to Environment.
+ def validate_environment(cls, v: dict | Any) -> EnvironmentType:
+ """Ensure environment has a type field for discrimination.
- Accepts:
- - Environment instance
- - dict that can be converted to Environment
+ For backward compatibility, if no type is specified, default to "uv".
"""
- if isinstance(v, Environment):
- return v
- elif isinstance(v, dict):
- return Environment(**v) # type: ignore[arg-type]
- else:
- raise ValueError("environment must be a dict, Environment instance")
+ if isinstance(v, dict):
+ return UVEnvironment(**v)
+ return v
@field_validator("deployment", mode="before")
@classmethod
@@ -504,22 +215,19 @@ class FastMCPConfig(BaseModel):
- dict that can be converted to Deployment
"""
- if isinstance(v, Deployment):
- return v
- elif isinstance(v, dict):
+ if isinstance(v, dict):
return Deployment(**v) # type: ignore[arg-type]
- else:
- raise ValueError("deployment must be a dict, Deployment instance")
+ return cast(Deployment, v)
@classmethod
- def from_file(cls, file_path: Path) -> FastMCPConfig:
+ def from_file(cls, file_path: Path) -> MCPServerConfig:
"""Load configuration from a JSON file.
Args:
file_path: Path to the configuration file
Returns:
- FastMCPConfig instance
+ MCPServerConfig instance
Raises:
FileNotFoundError: If the file doesn't exist
@@ -552,7 +260,7 @@ class FastMCPConfig(BaseModel):
env: dict[str, str] | None = None,
cwd: str | None = None,
args: list[str] | None = None,
- ) -> FastMCPConfig:
+ ) -> MCPServerConfig:
"""Create a config from CLI arguments.
This allows us to have a single code path where everything
@@ -575,12 +283,12 @@ class FastMCPConfig(BaseModel):
args: Server arguments
Returns:
- FastMCPConfig instance
+ MCPServerConfig instance
"""
# Build environment config if any env args provided
environment = None
if any([python, dependencies, requirements, project, editable]):
- environment = Environment(
+ environment = UVEnvironment(
python=python,
dependencies=dependencies,
requirements=requirements,
@@ -718,7 +426,7 @@ def generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | N
Returns:
JSON schema as a dictionary if output_path is None, otherwise None
"""
- schema = FastMCPConfig.model_json_schema()
+ schema = MCPServerConfig.model_json_schema()
# Add some metadata
schema["$id"] = FASTMCP_JSON_SCHEMA
diff --git a/src/fastmcp/utilities/fastmcp_config/v1/schema.json b/src/fastmcp/utilities/mcp_server_config/v1/schema.json
similarity index 97%
rename from src/fastmcp/utilities/fastmcp_config/v1/schema.json
rename to src/fastmcp/utilities/mcp_server_config/v1/schema.json
index c28a50452..fe2e21ce9 100644
--- a/src/fastmcp/utilities/fastmcp_config/v1/schema.json
+++ b/src/fastmcp/utilities/mcp_server_config/v1/schema.json
@@ -162,9 +162,49 @@
"title": "Deployment",
"type": "object"
},
- "Environment": {
+ "FileSystemSource": {
+ "description": "Source for local Python files.",
+ "properties": {
+ "type": {
+ "const": "filesystem",
+ "default": "filesystem",
+ "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"
+ },
+ "UVEnvironment": {
"description": "Configuration for Python environment setup.",
"properties": {
+ "type": {
+ "const": "uv",
+ "default": "uv",
+ "title": "Type",
+ "type": "string"
+ },
"python": {
"anyOf": [
{
@@ -266,42 +306,7 @@
"title": "Editable"
}
},
- "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",
+ "title": "UVEnvironment",
"type": "object"
}
},
@@ -339,7 +344,7 @@
]
},
"environment": {
- "$ref": "#/$defs/Environment",
+ "$ref": "#/$defs/UVEnvironment",
"description": "Python environment setup configuration"
},
"deployment": {
diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/__init__.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/__init__.py
similarity index 100%
rename from src/fastmcp/utilities/fastmcp_config/v1/sources/__init__.py
rename to src/fastmcp/utilities/mcp_server_config/v1/sources/__init__.py
diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py
similarity index 96%
rename from src/fastmcp/utilities/fastmcp_config/v1/sources/base.py
rename to src/fastmcp/utilities/mcp_server_config/v1/sources/base.py
index 1eeb593d9..fa6509353 100644
--- a/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py
+++ b/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py
@@ -4,7 +4,7 @@ from typing import Any
from pydantic import BaseModel, Field
-class BaseSource(BaseModel, ABC):
+class Source(BaseModel, ABC):
"""Abstract base class for all source types."""
type: str = Field(description="Source type identifier")
diff --git a/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py
similarity index 97%
rename from src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py
rename to src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py
index 92fb7ae3e..bddffe955 100644
--- a/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py
+++ b/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py
@@ -6,16 +6,17 @@ from typing import Any, Literal
from pydantic import Field, field_validator
-from fastmcp.utilities.fastmcp_config.v1.sources.base import BaseSource
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.mcp_server_config.v1.sources.base import Source
logger = get_logger(__name__)
-class FileSystemSource(BaseSource):
+class FileSystemSource(Source):
"""Source for local Python files."""
- type: Literal["filesystem"] = Field(default="filesystem", description="Source type")
+ type: Literal["filesystem"] = "filesystem"
+
path: str = Field(description="Path to Python file containing the server")
entrypoint: str | None = Field(
default=None,
diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py
index 1d0d078a6..0d8b57c08 100644
--- a/tests/cli/test_cli.py
+++ b/tests/cli/test_cli.py
@@ -458,7 +458,7 @@ class TestWindowsSpecific:
"""Test parsing Windows paths with drive letters and colons."""
from pathlib import Path
- from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import (
+ from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import (
FileSystemSource,
)
diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py
index 094b9bc52..a3cc76fd3 100644
--- a/tests/cli/test_config.py
+++ b/tests/cli/test_config.py
@@ -7,12 +7,12 @@ from pathlib import Path
import pytest
from pydantic import ValidationError
-from fastmcp.utilities.fastmcp_config import (
+from fastmcp.utilities.mcp_server_config import (
Deployment,
- Environment,
- FastMCPConfig,
+ MCPServerConfig,
)
-from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
+from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
class TestFileSystemSource:
@@ -20,7 +20,7 @@ class TestFileSystemSource:
def test_dict_source_minimal(self):
"""Test that dict source is converted to FileSystemSource."""
- config = FastMCPConfig(source={"path": "server.py"})
+ config = MCPServerConfig(source={"path": "server.py"})
# Dict is converted to FileSystemSource
assert isinstance(config.source, FileSystemSource)
assert config.source.path == "server.py"
@@ -29,7 +29,7 @@ class TestFileSystemSource:
def test_dict_source_with_entrypoint(self):
"""Test dict source with entrypoint field."""
- config = FastMCPConfig(source={"path": "server.py", "entrypoint": "app"})
+ config = MCPServerConfig(source={"path": "server.py", "entrypoint": "app"})
# Dict with entrypoint is converted to FileSystemSource
assert isinstance(config.source, FileSystemSource)
assert config.source.path == "server.py"
@@ -38,7 +38,7 @@ class TestFileSystemSource:
def test_filesystem_source_entrypoint(self):
"""Test FileSystemSource entrypoint format."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source=FileSystemSource(path="src/server.py", entrypoint="mcp")
)
assert isinstance(config.source, FileSystemSource)
@@ -52,7 +52,7 @@ class TestEnvironment:
def test_environment_config_fields(self):
"""Test all Environment fields."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
environment={
"python": "3.12",
@@ -73,28 +73,28 @@ class TestEnvironment:
def test_needs_uv(self):
"""Test needs_uv() method."""
# No environment config - doesn't need UV
- config = FastMCPConfig(source={"path": "server.py"})
+ config = MCPServerConfig(source={"path": "server.py"})
assert not config.environment.needs_uv()
# Empty environment - doesn't need UV
- config = FastMCPConfig(source={"path": "server.py"}, environment={})
+ config = MCPServerConfig(source={"path": "server.py"}, environment={})
assert not config.environment.needs_uv()
# With dependencies - needs UV
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"}, environment={"dependencies": ["requests"]}
)
assert config.environment.needs_uv()
# With Python version - needs UV
- config = FastMCPConfig(
+ config = MCPServerConfig(
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(
+ def test_build_uv_run_command(self):
+ """Test build_uv_run_command() method."""
+ config = MCPServerConfig(
source={"path": "server.py"},
environment={
"python": "3.12",
@@ -104,27 +104,28 @@ class TestEnvironment:
},
)
- args = config.environment.build_uv_args(["fastmcp", "run", "server.py"])
+ cmd = config.environment.build_command(["fastmcp", "run", "server.py"])
- assert args[0] == "run"
+ assert cmd[0] == "uv"
+ assert cmd[1] == "run"
# Python version not added when project is specified (project defines its own Python)
- assert "--python" not in args
- assert "3.12" not in args
- assert "--project" in args
- assert "." in args
- assert "--with" in args
- assert "requests" in args
- assert "numpy" in args
- assert "--with-requirements" in args
- assert "requirements.txt" in args
+ assert "--python" not in cmd
+ assert "3.12" not in cmd
+ assert "--project" in cmd
+ assert "." in cmd
+ assert "--with" in cmd
+ assert "requests" in cmd
+ assert "numpy" in cmd
+ assert "--with-requirements" in cmd
+ assert "requirements.txt" in cmd
# Command args should be at the end
- assert "fastmcp" in args[-3:]
- assert "run" in args[-2:]
- assert "server.py" in args[-1:]
+ assert "fastmcp" in cmd[-3:]
+ assert "run" in cmd[-2:]
+ assert "server.py" in cmd[-1:]
def test_run_with_uv(self):
"""Test run_with_uv() subprocess execution."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"}, environment={"dependencies": ["requests"]}
)
@@ -143,7 +144,7 @@ class TestDeployment:
def test_deployment_config_fields(self):
"""Test all Deployment fields."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
deployment={
"transport": "http",
@@ -175,7 +176,7 @@ class TestDeployment:
work_dir = tmp_path / "work"
work_dir.mkdir()
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
deployment={
"env": {"TEST_VAR": "test_value"},
@@ -211,7 +212,7 @@ class TestDeployment:
os.environ["BASE_URL"] = "example.com"
os.environ["ENV_NAME"] = "production"
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
deployment={
"env": {
@@ -252,17 +253,17 @@ class TestDeployment:
os.environ[key] = value
-class TestFastMCPConfig:
- """Test FastMCPConfig root configuration."""
+class TestMCPServerConfig:
+ """Test MCPServerConfig root configuration."""
def test_minimal_config(self):
"""Test creating a config with only required fields."""
- config = FastMCPConfig(source={"path": "server.py"})
+ config = MCPServerConfig(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, Environment)
+ assert isinstance(config.environment, UVEnvironment)
assert isinstance(config.deployment, Deployment)
# Check they have no values set
assert not config.environment.needs_uv()
@@ -273,7 +274,7 @@ class TestFastMCPConfig:
def test_nested_structure(self):
"""Test the nested configuration structure."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
environment={
"python": "3.12",
@@ -288,7 +289,7 @@ class TestFastMCPConfig:
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.environment, UVEnvironment)
assert isinstance(config.deployment, Deployment)
def test_from_file(self, tmp_path):
@@ -303,7 +304,7 @@ class TestFastMCPConfig:
config_file = tmp_path / "fastmcp.json"
config_file.write_text(json.dumps(config_data))
- config = FastMCPConfig.from_file(config_file)
+ config = MCPServerConfig.from_file(config_file)
# When loaded from JSON with entrypoint format, it becomes EntrypointConfig
assert isinstance(config.source, FileSystemSource)
@@ -324,7 +325,7 @@ class TestFastMCPConfig:
config_file = tmp_path / "fastmcp.json"
config_file.write_text(json.dumps(config_data))
- config = FastMCPConfig.from_file(config_file)
+ config = MCPServerConfig.from_file(config_file)
# String entrypoint with : should be converted to EntrypointConfig
assert isinstance(config.source, FileSystemSource)
assert config.source.path == "server.py"
@@ -341,7 +342,7 @@ class TestFastMCPConfig:
config_file = tmp_path / "fastmcp.json"
config_file.write_text(json.dumps(config_data))
- config = FastMCPConfig.from_file(config_file)
+ config = MCPServerConfig.from_file(config_file)
# Should be parsed into EntrypointConfig
assert isinstance(config.source, FileSystemSource)
@@ -364,7 +365,7 @@ class TestFastMCPConfig:
original_cwd = os.getcwd()
try:
os.chdir(tmp_path)
- found = FastMCPConfig.find_config()
+ found = MCPServerConfig.find_config()
assert found == config_file
finally:
os.chdir(original_cwd)
@@ -378,7 +379,7 @@ class TestFastMCPConfig:
subdir.mkdir()
# Should NOT find config in parent directory
- found = FastMCPConfig.find_config(subdir)
+ found = MCPServerConfig.find_config(subdir)
assert found is None
def test_find_config_in_specified_dir(self, tmp_path):
@@ -387,12 +388,12 @@ class TestFastMCPConfig:
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)
+ found = MCPServerConfig.find_config(tmp_path)
assert found == config_file
def test_find_config_not_found(self, tmp_path):
"""Test when config is not found."""
- found = FastMCPConfig.find_config(tmp_path)
+ found = MCPServerConfig.find_config(tmp_path)
assert found is None
def test_invalid_transport(self, tmp_path):
@@ -406,20 +407,20 @@ class TestFastMCPConfig:
config_file.write_text(json.dumps(config_data))
with pytest.raises(ValidationError):
- FastMCPConfig.from_file(config_file)
+ MCPServerConfig.from_file(config_file)
def test_optional_sections(self):
"""Test that all config sections are optional except source."""
# Only source is required
- config = FastMCPConfig(source={"path": "server.py"})
+ config = MCPServerConfig(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, Environment)
+ assert isinstance(config.environment, UVEnvironment)
assert isinstance(config.deployment, Deployment)
# Only environment with values
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"}, environment={"python": "3.12"}
)
assert config.environment.python == "3.12"
@@ -430,12 +431,14 @@ class TestFastMCPConfig:
)
# Only deployment with values
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"}, deployment={"transport": "http"}
)
- assert isinstance(config.environment, Environment)
+ assert isinstance(config.environment, UVEnvironment)
+ # Check all fields except 'type' which has a default value
assert all(
getattr(config.environment, field, None) is None
- for field in Environment.model_fields
+ for field in UVEnvironment.model_fields
+ if field != "type"
)
assert config.deployment.transport == "http"
diff --git a/tests/cli/test_fastmcp_config_integration.py b/tests/cli/test_mcp_server_config_integration.py
similarity index 91%
rename from tests/cli/test_fastmcp_config_integration.py
rename to tests/cli/test_mcp_server_config_integration.py
index 98070385f..1a25d14dd 100644
--- a/tests/cli/test_fastmcp_config_integration.py
+++ b/tests/cli/test_mcp_server_config_integration.py
@@ -7,7 +7,7 @@ from pathlib import Path
import pytest
from fastmcp.client import Client
-from fastmcp.utilities.fastmcp_config import FastMCPConfig
+from fastmcp.utilities.mcp_server_config import MCPServerConfig
@pytest.fixture
@@ -89,7 +89,7 @@ class TestConfigWithClient:
"""Test that a server loaded from config works with a client."""
# Load the config
config_file = server_with_config / "fastmcp.json"
- config = FastMCPConfig.from_file(config_file)
+ config = MCPServerConfig.from_file(config_file)
# Import the server using the source
import importlib.util
@@ -132,7 +132,7 @@ class TestEnvironmentExecution:
def test_needs_uv_with_dependencies(self):
"""Test that environment with dependencies needs UV."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
environment={"dependencies": ["requests", "numpy"]}, # type: ignore[arg-type]
)
@@ -142,7 +142,7 @@ class TestEnvironmentExecution:
def test_needs_uv_with_python_version(self):
"""Test that environment with Python version needs UV."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
environment={"python": "3.12"}, # type: ignore[arg-type]
)
@@ -152,7 +152,7 @@ class TestEnvironmentExecution:
def test_no_uv_needed_without_environment(self):
"""Test that no UV is needed without environment config."""
- config = FastMCPConfig(source={"path": "server.py"})
+ config = MCPServerConfig(source={"path": "server.py"})
# Environment is now always present but may be empty
assert config.environment is not None
@@ -160,7 +160,7 @@ class TestEnvironmentExecution:
def test_no_uv_needed_with_empty_environment(self):
"""Test that no UV is needed with empty environment config."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
environment={}, # type: ignore[arg-type]
)
@@ -184,7 +184,7 @@ class TestPathResolution:
server_file = src_dir / "server.py"
server_file.write_text("# Server")
- config = FastMCPConfig(source={"path": "../src/server.py"})
+ config = MCPServerConfig(source={"path": "../src/server.py"})
# The source path is resolved during load_server
# For now, just check that the source is created correctly
@@ -198,7 +198,7 @@ class TestPathResolution:
work_dir = tmp_path / "work"
work_dir.mkdir()
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
deployment={"cwd": "work"}, # type: ignore[arg-type]
)
@@ -222,19 +222,19 @@ class TestPathResolution:
reqs_file = tmp_path / "requirements.txt"
reqs_file.write_text("fastmcp>=2.0")
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
environment={"requirements": "requirements.txt"}, # type: ignore[arg-type]
)
- # Build UV args
+ # Build UV command
assert config.environment is not None
- uv_args = config.environment.build_uv_args(["fastmcp", "run"])
+ uv_cmd = config.environment.build_command(["fastmcp", "run"])
# Should include requirements file
- assert "--with-requirements" in uv_args
- req_idx = uv_args.index("--with-requirements") + 1
- assert uv_args[req_idx] == "requirements.txt"
+ assert "--with-requirements" in uv_cmd
+ req_idx = uv_cmd.index("--with-requirements") + 1
+ assert uv_cmd[req_idx] == "requirements.txt"
class TestConfigValidation:
@@ -243,7 +243,7 @@ class TestConfigValidation:
def test_invalid_transport_rejected(self):
"""Test that invalid transport values are rejected."""
with pytest.raises(ValueError):
- FastMCPConfig(
+ MCPServerConfig(
source={"path": "server.py"},
deployment={"transport": "invalid_transport"}, # type: ignore[arg-type]
)
@@ -251,7 +251,7 @@ class TestConfigValidation:
def test_streamable_http_transport_rejected(self):
"""Test that streamable-http transport is rejected in fastmcp.json config."""
with pytest.raises(ValueError):
- FastMCPConfig(
+ MCPServerConfig(
source={"path": "server.py"},
deployment={"transport": "streamable-http"}, # type: ignore[arg-type]
)
@@ -259,7 +259,7 @@ class TestConfigValidation:
def test_invalid_log_level_rejected(self):
"""Test that invalid log level values are rejected."""
with pytest.raises(ValueError):
- FastMCPConfig(
+ MCPServerConfig(
source={"path": "server.py"},
deployment={"log_level": "INVALID"}, # type: ignore[arg-type]
)
@@ -267,12 +267,12 @@ class TestConfigValidation:
def test_missing_source_rejected(self):
"""Test that config without source is rejected."""
with pytest.raises(ValueError):
- FastMCPConfig() # type: ignore[call-arg]
+ MCPServerConfig() # type: ignore[call-arg]
def test_valid_transport_values(self):
"""Test that all valid transport values are accepted."""
for transport in ["stdio", "http", "sse"]:
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
deployment={"transport": transport}, # type: ignore[arg-type]
)
@@ -282,7 +282,7 @@ class TestConfigValidation:
def test_valid_log_levels(self):
"""Test that all valid log levels are accepted."""
for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
- config = FastMCPConfig(
+ config = MCPServerConfig(
source={"path": "server.py"},
deployment={"log_level": level}, # type: ignore[arg-type]
)
diff --git a/tests/cli/test_fastmcp_config_schema.py b/tests/cli/test_mcp_server_config_schema.py
similarity index 81%
rename from tests/cli/test_fastmcp_config_schema.py
rename to tests/cli/test_mcp_server_config_schema.py
index 5c05cd65f..d4f739911 100644
--- a/tests/cli/test_fastmcp_config_schema.py
+++ b/tests/cli/test_mcp_server_config_schema.py
@@ -1,39 +1,6 @@
-"""Test that the JSON schema file matches the Pydantic model."""
+"""Test that the generated JSON schema has the correct structure."""
-import json
-from pathlib import Path
-
-from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import generate_schema
-
-
-def test_schema_file_matches_pydantic_model():
- """Test that the schema.json file matches what the Pydantic model generates."""
- # Path to the schema file
- schema_file = (
- Path(__file__).parent.parent.parent
- / "src"
- / "fastmcp"
- / "utilities"
- / "fastmcp_config"
- / "v1"
- / "schema.json"
- )
-
- # Load the schema file
- with open(schema_file) as f:
- file_schema = json.load(f)
-
- # Generate schema from Pydantic model
- generated_schema = generate_schema()
-
- # They should be identical
- assert file_schema == generated_schema, (
- "The schema.json file does not match the Pydantic model schema. "
- "Please regenerate the schema file by running:\n"
- 'uv run python -c "from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import generate_schema; '
- 'import json; print(json.dumps(generate_schema(), indent=2))" > '
- f"{schema_file}"
- )
+from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import generate_schema
def test_schema_has_correct_id():
@@ -72,8 +39,22 @@ def test_schema_nested_structure():
# Check environment section
assert "environment" in properties
env_schema = properties["environment"]
- if "properties" in env_schema:
+ # Environment can be in anyOf or direct properties
+ if "anyOf" in env_schema:
+ # Find the UVEnvironment in anyOf
+ for option in env_schema["anyOf"]:
+ if option.get("type") == "object" and "properties" in option:
+ env_props = option["properties"]
+ assert "type" in env_props # New type field
+ assert "python" in env_props
+ assert "dependencies" in env_props
+ assert "requirements" in env_props
+ assert "project" in env_props
+ assert "editable" in env_props
+ break
+ elif "properties" in env_schema:
env_props = env_schema["properties"]
+ assert "type" in env_props # New type field
assert "python" in env_props
assert "dependencies" in env_props
assert "requirements" in env_props
diff --git a/tests/cli/test_project_prepare.py b/tests/cli/test_project_prepare.py
index d23f61f32..88e3fe5c2 100644
--- a/tests/cli/test_project_prepare.py
+++ b/tests/cli/test_project_prepare.py
@@ -6,26 +6,27 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
-from fastmcp.utilities.fastmcp_config import Environment, FastMCPConfig
-from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
+from fastmcp.utilities.mcp_server_config import MCPServerConfig
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
+from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
-class TestFastMCPConfigPrepare:
- """Test the FastMCPConfig.prepare() method."""
+class TestMCPServerConfigPrepare:
+ """Test the MCPServerConfig.prepare() method."""
@patch(
- "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_source",
+ "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source",
new_callable=AsyncMock,
)
@patch(
- "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_environment",
+ "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_environment",
new_callable=AsyncMock,
)
async def test_prepare_calls_both_methods(self, mock_env, mock_src):
"""Test that prepare() calls both prepare_environment and prepare_source."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source=FileSystemSource(path="server.py"),
- environment=Environment(python="3.10"),
+ environment=UVEnvironment(python="3.10"),
)
await config.prepare()
@@ -34,18 +35,18 @@ class TestFastMCPConfigPrepare:
mock_src.assert_called_once()
@patch(
- "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_source",
+ "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source",
new_callable=AsyncMock,
)
@patch(
- "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_environment",
+ "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_environment",
new_callable=AsyncMock,
)
async def test_prepare_with_output_dir(self, mock_env, mock_src):
"""Test that prepare() with output_dir calls prepare_environment with it."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source=FileSystemSource(path="server.py"),
- environment=Environment(python="3.10"),
+ environment=UVEnvironment(python="3.10"),
)
output_path = Path("/tmp/test-env")
@@ -55,18 +56,18 @@ class TestFastMCPConfigPrepare:
mock_src.assert_called_once()
@patch(
- "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_source",
+ "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source",
new_callable=AsyncMock,
)
@patch(
- "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_environment",
+ "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_environment",
new_callable=AsyncMock,
)
async def test_prepare_skip_source(self, mock_env, mock_src):
"""Test that prepare() skips source when skip_source=True."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source=FileSystemSource(path="server.py"),
- environment=Environment(python="3.10"),
+ environment=UVEnvironment(python="3.10"),
)
await config.prepare(skip_source=True)
@@ -75,16 +76,16 @@ class TestFastMCPConfigPrepare:
mock_src.assert_not_called()
@patch(
- "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.FastMCPConfig.prepare_source",
+ "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source",
new_callable=AsyncMock,
)
@patch(
- "fastmcp.utilities.fastmcp_config.v1.fastmcp_config.Environment.prepare",
+ "fastmcp.utilities.mcp_server_config.v1.environments.uv.UVEnvironment.prepare",
new_callable=AsyncMock,
)
async def test_prepare_no_environment_settings(self, mock_env_prepare, mock_src):
"""Test that prepare() works with default empty environment config."""
- config = FastMCPConfig(
+ config = MCPServerConfig(
source=FileSystemSource(path="server.py"),
# environment defaults to empty Environment()
)
@@ -104,7 +105,7 @@ class TestEnvironmentPrepare:
"""Test that prepare() raises error when uv is not installed."""
mock_which.return_value = None
- env = Environment(python="3.10")
+ env = UVEnvironment(python="3.10")
with pytest.raises(RuntimeError, match="uv is not installed"):
await env.prepare(tmp_path / "test-env")
@@ -115,7 +116,7 @@ class TestEnvironmentPrepare:
"""Test that prepare() does nothing when no settings are configured."""
mock_which.return_value = "/usr/bin/uv"
- env = Environment() # No settings
+ env = UVEnvironment() # No settings
await env.prepare(tmp_path / "test-env")
@@ -131,7 +132,7 @@ class TestEnvironmentPrepare:
returncode=0, stdout="Environment cached", stderr=""
)
- env = Environment(python="3.10")
+ env = UVEnvironment(python="3.10")
await env.prepare(tmp_path / "test-env")
@@ -150,7 +151,7 @@ class TestEnvironmentPrepare:
mock_which.return_value = "/usr/bin/uv"
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
- env = Environment(dependencies=["numpy", "pandas"])
+ env = UVEnvironment(dependencies=["numpy", "pandas"])
await env.prepare(tmp_path / "test-env")
@@ -179,7 +180,7 @@ class TestEnvironmentPrepare:
1, ["uv"], stderr="Package not found"
)
- env = Environment(python="3.10")
+ env = UVEnvironment(python="3.10")
with pytest.raises(RuntimeError, match="Failed to initialize project"):
await env.prepare(tmp_path / "test-env")
@@ -188,8 +189,8 @@ class TestEnvironmentPrepare:
class TestProjectPrepareCommand:
"""Test the CLI project prepare command."""
- @patch("fastmcp.utilities.fastmcp_config.FastMCPConfig.from_file")
- @patch("fastmcp.utilities.fastmcp_config.FastMCPConfig.find_config")
+ @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.from_file")
+ @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.find_config")
async def test_project_prepare_auto_detect(self, mock_find, mock_from_file):
"""Test project prepare with auto-detected config."""
from fastmcp.cli.cli import prepare
@@ -220,7 +221,7 @@ class TestProjectPrepareCommand:
assert "Project prepared successfully" in success_call
@patch("pathlib.Path.exists")
- @patch("fastmcp.utilities.fastmcp_config.FastMCPConfig.from_file")
+ @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.from_file")
async def test_project_prepare_explicit_path(self, mock_from_file, mock_exists):
"""Test project prepare with explicit config path."""
from fastmcp.cli.cli import prepare
@@ -243,7 +244,7 @@ class TestProjectPrepareCommand:
output_dir=Path("./test-env"),
)
- @patch("fastmcp.utilities.fastmcp_config.FastMCPConfig.find_config")
+ @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.find_config")
async def test_project_prepare_no_config_found(self, mock_find):
"""Test project prepare when no config is found."""
from fastmcp.cli.cli import prepare
@@ -280,7 +281,7 @@ class TestProjectPrepareCommand:
assert "--output-dir parameter is required" in error_msg
@patch("pathlib.Path.exists")
- @patch("fastmcp.utilities.fastmcp_config.FastMCPConfig.from_file")
+ @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.from_file")
async def test_project_prepare_failure(self, mock_from_file, mock_exists):
"""Test project prepare when prepare() fails."""
from fastmcp.cli.cli import prepare
diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py
index f5375f7e0..685e8aff0 100644
--- a/tests/cli/test_run.py
+++ b/tests/cli/test_run.py
@@ -13,7 +13,7 @@ from fastmcp.client.client import Client
from fastmcp.client.transports import FastMCPTransport
from fastmcp.mcp_config import MCPConfig, StdioMCPServer
from fastmcp.server.server import FastMCP
-from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
+from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
class TestUrlDetection:
@@ -339,7 +339,7 @@ mcp = fastmcp.FastMCP("TestServer")
from unittest.mock import AsyncMock, patch
from fastmcp.cli.run import run_command
- from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import (
+ from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import (
FileSystemSource,
)
@@ -368,7 +368,7 @@ mcp = fastmcp.FastMCP("TestServer")
from unittest.mock import AsyncMock, patch
from fastmcp.cli.run import run_command
- from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import (
+ from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import (
FileSystemSource,
)
diff --git a/tests/cli/test_run_config.py b/tests/cli/test_run_config.py
index 5ef7c26e1..b8114aa07 100644
--- a/tests/cli/test_run_config.py
+++ b/tests/cli/test_run_config.py
@@ -6,13 +6,13 @@ from pathlib import Path
import pytest
-from fastmcp.cli.run import load_fastmcp_config
-from fastmcp.utilities.fastmcp_config import (
+from fastmcp.cli.run import load_mcp_server_config
+from fastmcp.utilities.mcp_server_config import (
Deployment,
- Environment,
- FastMCPConfig,
+ MCPServerConfig,
)
-from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
+from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
@pytest.fixture
@@ -43,20 +43,20 @@ def test_tool(message: str) -> str:
return config_file
-def test_load_fastmcp_config(sample_config, monkeypatch):
+def test_load_mcp_server_config(sample_config, monkeypatch):
"""Test loading configuration and returning config subsets."""
# Capture environment changes
original_env = dict(os.environ)
try:
- config = load_fastmcp_config(sample_config)
+ config = load_mcp_server_config(sample_config)
# Check that we got the right types
- assert isinstance(config, FastMCPConfig)
+ assert isinstance(config, MCPServerConfig)
assert isinstance(config.source, FileSystemSource)
assert isinstance(config.deployment, Deployment)
- assert isinstance(config.environment, Environment)
+ assert isinstance(config.environment, UVEnvironment)
# Check source - path is not resolved yet, only during load_server
assert config.source.path == "server.py"
@@ -95,7 +95,7 @@ def test_load_config_with_entrypoint_source(tmp_path):
server_file = src_dir / "server.py"
server_file.write_text("# Server")
- config = load_fastmcp_config(config_file)
+ config = load_mcp_server_config(config_file)
# Check source - path is not resolved yet, only during load_server
assert config.source.path == "src/server.py"
@@ -125,7 +125,7 @@ def test_load_config_with_cwd(tmp_path):
original_cwd = os.getcwd()
try:
- config = load_fastmcp_config(config_file) # noqa: F841
+ config = load_mcp_server_config(config_file) # noqa: F841
# Check that working directory was changed
assert Path.cwd() == subdir.resolve()
@@ -160,7 +160,7 @@ def test_load_config_with_relative_cwd(tmp_path):
original_cwd = os.getcwd()
try:
- config = load_fastmcp_config(config_file) # noqa: F841
+ config = load_mcp_server_config(config_file) # noqa: F841
# Should change to parent directory of config file
assert Path.cwd() == subdir1.resolve()
@@ -180,7 +180,7 @@ def test_load_minimal_config(tmp_path):
server_file = tmp_path / "server.py"
server_file.write_text("# Server")
- config = load_fastmcp_config(config_file)
+ config = load_mcp_server_config(config_file)
# Check we got source - path is not resolved yet, only during load_server
assert isinstance(config.source, FileSystemSource)
@@ -201,7 +201,7 @@ def test_load_config_with_server_args(tmp_path):
server_file = tmp_path / "server.py"
server_file.write_text("# Server")
- config = load_fastmcp_config(config_file)
+ config = load_mcp_server_config(config_file)
assert config.deployment.args == ["--debug", "--config", "custom.json"]
@@ -221,7 +221,7 @@ def test_config_subset_independence(tmp_path):
server_file = tmp_path / "server.py"
server_file.write_text("# Server")
- config = load_fastmcp_config(config_file)
+ config = load_mcp_server_config(config_file)
# Each subset should be independently usable
# Path is not resolved yet, only during load_server
@@ -259,15 +259,13 @@ def test_environment_config_path_resolution(tmp_path):
server_file = tmp_path / "server.py"
server_file.write_text("# Server")
- config = load_fastmcp_config(config_file)
+ config = load_mcp_server_config(config_file)
- # Check that UV args are built with resolved paths
- uv_args = config.environment.build_uv_args(["fastmcp", "run", "server.py"])
+ # Check that UV command is built with resolved paths
+ uv_cmd = config.environment.build_command(["fastmcp", "run", "server.py"])
- assert "--with-requirements" in uv_args
- assert "--project" in uv_args
+ assert "--with-requirements" in uv_cmd
+ assert "--project" in uv_cmd
# Path should be resolved relative to config file
- req_idx = uv_args.index("--with-requirements") + 1
- assert (
- Path(uv_args[req_idx]).is_absolute() or uv_args[req_idx] == "requirements.txt"
- )
+ req_idx = uv_cmd.index("--with-requirements") + 1
+ assert Path(uv_cmd[req_idx]).is_absolute() or uv_cmd[req_idx] == "requirements.txt"
diff --git a/tests/cli/test_run_with_uv.py b/tests/cli/test_run_with_uv.py
index 027939191..3bc302269 100644
--- a/tests/cli/test_run_with_uv.py
+++ b/tests/cli/test_run_with_uv.py
@@ -25,16 +25,17 @@ class TestRunWithUv:
# Check the command that was called
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
+ env = mock_run.call_args.kwargs.get("env", {})
+ # With no environment config, the command should be returned unchanged
expected = [
- "uv",
- "run",
"fastmcp",
"run",
- "--skip-env",
"server.py",
]
assert cmd == expected
+ # Check that the environment marker is set
+ assert env.get("FASTMCP_UV_SPAWNED") == "1"
@patch("subprocess.run")
def test_run_with_uv_python_version(self, mock_run):
@@ -54,7 +55,6 @@ class TestRunWithUv:
"3.11",
"fastmcp",
"run",
- "--skip-env",
"server.py",
]
assert cmd == expected
@@ -80,7 +80,6 @@ class TestRunWithUv:
assert cmd[4:] == [
"fastmcp",
"run",
- "--skip-env",
"server.py",
]
@@ -104,7 +103,6 @@ class TestRunWithUv:
"numpy", # original order preserved
"fastmcp",
"run",
- "--skip-env",
"server.py",
]
assert cmd == expected
@@ -128,7 +126,6 @@ class TestRunWithUv:
str(req_path.resolve()), # auto-resolved to absolute path
"fastmcp",
"run",
- "--skip-env",
"server.py",
]
assert cmd == expected
@@ -152,12 +149,10 @@ class TestRunWithUv:
assert exc_info.value.code == 0
cmd = mock_run.call_args[0][0]
+ # With no environment config, no uv run prefix
expected = [
- "uv",
- "run",
"fastmcp",
"run",
- "--skip-env",
"server.py",
"--transport",
"http",
@@ -220,7 +215,6 @@ class TestRunWithUv:
assert cmd[next_idx:] == [
"fastmcp",
"run",
- "--skip-env",
"server.py",
"--transport",
"http",
diff --git a/tests/cli/test_server_args.py b/tests/cli/test_server_args.py
index 5ec7ccc6a..8f1782dbf 100644
--- a/tests/cli/test_server_args.py
+++ b/tests/cli/test_server_args.py
@@ -4,8 +4,8 @@ from pathlib import Path
import pytest
-from fastmcp.utilities.fastmcp_config import FastMCPConfig
-from fastmcp.utilities.fastmcp_config.v1.sources.filesystem import FileSystemSource
+from fastmcp.utilities.mcp_server_config import MCPServerConfig
+from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource
class TestServerArguments:
@@ -39,7 +39,7 @@ def get_config() -> dict:
# Test with arguments
source = FileSystemSource(path=str(server_file))
- config = FastMCPConfig(source=source)
+ config = MCPServerConfig(source=source)
from fastmcp.cli.cli import with_argv
@@ -69,7 +69,7 @@ mcp = FastMCP(args.name)
""")
source = FileSystemSource(path=str(server_file))
- config = FastMCPConfig(source=source)
+ config = MCPServerConfig(source=source)
from fastmcp.cli.cli import with_argv
@@ -96,7 +96,7 @@ mcp = FastMCP(name)
""")
source = FileSystemSource(path=str(server_file))
- config = FastMCPConfig(source=source)
+ config = MCPServerConfig(source=source)
from fastmcp.cli.cli import with_argv
@@ -123,7 +123,7 @@ mcp = FastMCP(name)
pytest.skip("config_server.py example not found")
source = FileSystemSource(path=str(config_server))
- config = FastMCPConfig(source=source)
+ config = MCPServerConfig(source=source)
from fastmcp.cli.cli import with_argv
diff --git a/tests/client/auth/test_oauth_token_expiry.py b/tests/client/auth/test_oauth_token_expiry.py
new file mode 100644
index 000000000..77e5d552e
--- /dev/null
+++ b/tests/client/auth/test_oauth_token_expiry.py
@@ -0,0 +1,152 @@
+"""Test OAuth token expiry handling with absolute timestamps."""
+
+import json
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+import pytest
+from mcp.shared.auth import OAuthToken
+
+from fastmcp.client.auth.oauth import FileTokenStorage
+
+
+@pytest.mark.asyncio
+async def test_token_storage_with_expiry(tmp_path: Path):
+ """Test that tokens are stored with absolute expiry time and loaded correctly."""
+ storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
+
+ # Create a token with 3600 seconds expiry
+ token = OAuthToken(
+ access_token="test_token",
+ token_type="Bearer",
+ expires_in=3600,
+ refresh_token="refresh_token",
+ )
+
+ # Save the token
+ await storage.set_tokens(token)
+
+ # Check that the file contains the dataclass format
+ token_file = storage._get_file_path("tokens")
+ data = json.loads(token_file.read_text())
+
+ assert "token_payload" in data
+ assert "expires_at" in data
+ assert data["expires_at"] is not None
+ # expires_at should be approximately now + 3600 seconds
+ expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00"))
+ expected = datetime.now(timezone.utc) + timedelta(seconds=3600)
+ assert abs((expires_at - expected).total_seconds()) < 2
+
+ # Load the token back
+ loaded_token = await storage.get_tokens()
+ assert loaded_token is not None
+ assert loaded_token.access_token == "test_token"
+ # expires_in should be recalculated to be approximately 3600 (minus loading time)
+ assert loaded_token.expires_in is not None
+ assert 3595 <= loaded_token.expires_in <= 3600
+
+
+@pytest.mark.asyncio
+async def test_expired_token_returns_none(tmp_path: Path):
+ """Test that expired tokens return None when loaded."""
+ storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
+
+ # Manually create an already-expired token file
+ token_file = storage._get_file_path("tokens")
+ past_expiry = datetime.now(timezone.utc) - timedelta(
+ seconds=10
+ ) # Expired 10 seconds ago
+
+ expired_token = {
+ "token_payload": {
+ "access_token": "test_token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "refresh_token": "refresh_token",
+ },
+ "expires_at": past_expiry.isoformat(),
+ }
+ token_file.write_text(json.dumps(expired_token, indent=2, default=str))
+
+ # Load the token - should return None since it's expired
+ loaded_token = await storage.get_tokens()
+ assert loaded_token is None
+
+
+@pytest.mark.asyncio
+async def test_token_without_expiry(tmp_path: Path):
+ """Test that tokens without expires_in are handled correctly."""
+ storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
+
+ # Create a token without expires_in (perpetual token)
+ token = OAuthToken(
+ access_token="test_token",
+ token_type="Bearer",
+ expires_in=None,
+ refresh_token="refresh_token",
+ )
+
+ # Save the token
+ await storage.set_tokens(token)
+
+ # Check that expires_at is None in the file
+ token_file = storage._get_file_path("tokens")
+ data = json.loads(token_file.read_text())
+ assert data["expires_at"] is None
+
+ # Load the token back - should work since no expiry
+ loaded_token = await storage.get_tokens()
+ assert loaded_token is not None
+ assert loaded_token.access_token == "test_token"
+ assert loaded_token.expires_in is None
+
+
+@pytest.mark.asyncio
+async def test_invalid_format_returns_none(tmp_path: Path):
+ """Test that invalid token format returns None."""
+ storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
+
+ # Manually write an invalid format token file (missing required fields)
+ token_file = storage._get_file_path("tokens")
+ invalid_token = {
+ "access_token": "invalid_token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "refresh_token": "refresh_token",
+ }
+ token_file.write_text(json.dumps(invalid_token, indent=2))
+
+ # Try to load - should return None
+ loaded_token = await storage.get_tokens()
+ assert loaded_token is None
+
+
+@pytest.mark.asyncio
+async def test_token_expiry_recalculated_on_load(tmp_path: Path):
+ """Test that expires_in is correctly recalculated when loading tokens."""
+ storage = FileTokenStorage("http://test.example.com", cache_dir=tmp_path)
+
+ # Manually create a token file with a specific expires_at
+ token_file = storage._get_file_path("tokens")
+ future_expiry = datetime.now(timezone.utc) + timedelta(
+ seconds=1800
+ ) # 30 minutes from now
+
+ stored_token = {
+ "token_payload": {
+ "access_token": "test_token",
+ "token_type": "Bearer",
+ "expires_in": 3600, # Original value (will be recalculated)
+ "refresh_token": "refresh_token",
+ },
+ "expires_at": future_expiry.isoformat(),
+ }
+ token_file.write_text(json.dumps(stored_token, indent=2, default=str))
+
+ # Load the token
+ loaded_token = await storage.get_tokens()
+ assert loaded_token is not None
+ # expires_in should be recalculated to approximately 1800 seconds
+ assert loaded_token.expires_in is not None
+ assert 1795 <= loaded_token.expires_in <= 1800
diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py
index 56b3e9729..28019f39a 100644
--- a/tests/server/auth/test_oauth_proxy.py
+++ b/tests/server/auth/test_oauth_proxy.py
@@ -220,6 +220,7 @@ class TestOAuthProxyComprehensive:
assert stored_client is not None
assert stored_client.client_id == "test-client-id"
assert stored_client.client_secret == "test-client-secret"
+ assert stored_client.scope == "read write"
async def test_register_client_empty_grant_types(self, oauth_proxy):
"""Test client registration with empty grant types."""
@@ -266,6 +267,7 @@ class TestOAuthProxyComprehensive:
assert len(temp_client.redirect_uris) >= 1
# ProxyDCRClient uses a placeholder URL but accepts any localhost URI
assert str(temp_client.redirect_uris[0]) == "http://localhost/"
+ assert temp_client.scope == "read write"
# Test that it accepts any localhost redirect URI
from pydantic import AnyUrl
@@ -379,6 +381,20 @@ class TestOAuthProxyComprehensive:
# Proxy should NOT add any scopes - providers handle their own defaults
assert "scope" not in query_params
+ async def test_client_scope_empty_when_no_required_scopes(self):
+ """When required_scopes is None/empty, client scope should be empty string."""
+ proxy = OAuthProxy(
+ upstream_authorization_endpoint="https://auth.example.com/authorize",
+ upstream_token_endpoint="https://auth.example.com/token",
+ upstream_client_id="client-123",
+ upstream_client_secret="secret-456",
+ token_verifier=Mock(required_scopes=None),
+ base_url="https://api.example.com",
+ )
+
+ temp_client = await proxy.get_client("any-client")
+ assert temp_client.scope == ""
+
async def test_load_authorization_code_valid(self, oauth_proxy):
"""Test loading a valid authorization code."""
# Store a client code
diff --git a/tests/server/auth/test_oauth_proxy_redirect_validation.py b/tests/server/auth/test_oauth_proxy_redirect_validation.py
index bfb8c8108..10b44218e 100644
--- a/tests/server/auth/test_oauth_proxy_redirect_validation.py
+++ b/tests/server/auth/test_oauth_proxy_redirect_validation.py
@@ -21,15 +21,15 @@ class MockTokenVerifier(TokenVerifier):
class TestProxyDCRClient:
"""Test ProxyDCRClient redirect URI validation."""
- def test_default_localhost_only(self):
- """Test that default configuration only allows localhost."""
+ def test_default_allows_all(self):
+ """Test that default configuration allows all URIs for DCR compatibility."""
client = ProxyDCRClient(
client_id="test",
client_secret="secret",
redirect_uris=[AnyUrl("http://localhost:3000")],
)
- # Localhost should be allowed
+ # All URIs should be allowed by default for DCR compatibility
assert client.validate_redirect_uri(AnyUrl("http://localhost:3000")) == AnyUrl(
"http://localhost:3000"
)
@@ -39,11 +39,12 @@ class TestProxyDCRClient:
assert client.validate_redirect_uri(AnyUrl("http://127.0.0.1:3000")) == AnyUrl(
"http://127.0.0.1:3000"
)
-
- # Non-localhost should fallback to base validation
- # This will check against registered redirect_uris
- with pytest.raises(InvalidRedirectUriError):
- client.validate_redirect_uri(AnyUrl("http://example.com"))
+ assert client.validate_redirect_uri(AnyUrl("http://example.com")) == AnyUrl(
+ "http://example.com"
+ )
+ assert client.validate_redirect_uri(
+ AnyUrl("https://claude.ai/api/mcp/auth_callback")
+ ) == AnyUrl("https://claude.ai/api/mcp/auth_callback")
def test_custom_patterns(self):
"""Test custom redirect URI patterns."""
@@ -65,8 +66,8 @@ class TestProxyDCRClient:
with pytest.raises(InvalidRedirectUriError):
client.validate_redirect_uri(AnyUrl("http://127.0.0.1:3000"))
- def test_empty_list_allows_all(self):
- """Test that empty pattern list allows all URIs."""
+ def test_empty_list_allows_none(self):
+ """Test that empty pattern list allows no URIs."""
client = ProxyDCRClient(
client_id="test",
client_secret="secret",
@@ -74,10 +75,15 @@ class TestProxyDCRClient:
allowed_redirect_uri_patterns=[],
)
- # Everything should be allowed
+ # Nothing should be allowed (except the pre-registered one via fallback)
+ # Pre-registered URI should work via fallback to base validation
assert client.validate_redirect_uri(AnyUrl("http://localhost:3000"))
- assert client.validate_redirect_uri(AnyUrl("http://example.com"))
- assert client.validate_redirect_uri(AnyUrl("https://anywhere.com:9999/path"))
+
+ # Non-registered URIs should be rejected
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("http://example.com"))
+ with pytest.raises(InvalidRedirectUriError):
+ client.validate_redirect_uri(AnyUrl("https://anywhere.com:9999/path"))
def test_none_redirect_uri(self):
"""Test that None redirect URI uses default behavior."""
@@ -95,8 +101,8 @@ class TestProxyDCRClient:
class TestOAuthProxyRedirectValidation:
"""Test OAuth proxy with redirect URI validation."""
- def test_proxy_default_localhost_validation(self):
- """Test that OAuth proxy defaults to localhost-only validation."""
+ def test_proxy_default_allows_all(self):
+ """Test that OAuth proxy defaults to allowing all URIs for DCR compatibility."""
proxy = OAuthProxy(
upstream_authorization_endpoint="https://auth.example.com/authorize",
upstream_token_endpoint="https://auth.example.com/token",
@@ -106,7 +112,7 @@ class TestOAuthProxyRedirectValidation:
base_url="http://localhost:8000",
)
- # The proxy should store None for default localhost patterns
+ # The proxy should store None for default (allow all)
assert proxy._allowed_client_redirect_uris is None
def test_proxy_custom_patterns(self):
@@ -126,7 +132,7 @@ class TestOAuthProxyRedirectValidation:
assert proxy._allowed_client_redirect_uris == custom_patterns
def test_proxy_empty_list_validation(self):
- """Test OAuth proxy with empty list (allow all)."""
+ """Test OAuth proxy with empty list (allow none)."""
proxy = OAuthProxy(
upstream_authorization_endpoint="https://auth.example.com/authorize",
upstream_token_endpoint="https://auth.example.com/token",
diff --git a/tests/server/auth/test_redirect_validation.py b/tests/server/auth/test_redirect_validation.py
index 53d96e356..87071a91f 100644
--- a/tests/server/auth/test_redirect_validation.py
+++ b/tests/server/auth/test_redirect_validation.py
@@ -66,21 +66,20 @@ class TestValidateRedirectUri:
assert validate_redirect_uri(None, [])
assert validate_redirect_uri(None, ["http://localhost:*"])
- def test_default_localhost_patterns(self):
- """Test default localhost-only patterns when None is provided."""
- # Localhost patterns should be allowed by default
+ def test_default_allows_all(self):
+ """Test that None (default) allows all URIs for DCR compatibility."""
+ # All URIs should be allowed when None is provided (DCR compatibility)
assert validate_redirect_uri("http://localhost:3000", None)
assert validate_redirect_uri("http://127.0.0.1:8080", None)
+ assert validate_redirect_uri("http://example.com", None)
+ assert validate_redirect_uri("https://app.example.com", None)
+ assert validate_redirect_uri("https://claude.ai/api/mcp/auth_callback", None)
- # Non-localhost should be rejected by default
- assert not validate_redirect_uri("http://example.com", None)
- assert not validate_redirect_uri("https://app.example.com", None)
-
- def test_empty_list_allows_all(self):
- """Test that empty list allows all redirect URIs."""
- assert validate_redirect_uri("http://localhost:3000", [])
- assert validate_redirect_uri("http://example.com", [])
- assert validate_redirect_uri("https://anywhere.com:9999/path", [])
+ def test_empty_list_allows_none(self):
+ """Test that empty list allows no redirect URIs."""
+ assert not validate_redirect_uri("http://localhost:3000", [])
+ assert not validate_redirect_uri("http://example.com", [])
+ assert not validate_redirect_uri("https://anywhere.com:9999/path", [])
def test_custom_patterns(self):
"""Test validation with custom pattern list."""
@@ -122,3 +121,21 @@ class TestDefaultPatterns:
"""Test that default patterns include localhost variations."""
assert "http://localhost:*" in DEFAULT_LOCALHOST_PATTERNS
assert "http://127.0.0.1:*" in DEFAULT_LOCALHOST_PATTERNS
+
+ def test_explicit_localhost_patterns(self):
+ """Test that explicitly passing DEFAULT_LOCALHOST_PATTERNS restricts to localhost."""
+ # Localhost patterns should be allowed
+ assert validate_redirect_uri(
+ "http://localhost:3000", DEFAULT_LOCALHOST_PATTERNS
+ )
+ assert validate_redirect_uri(
+ "http://127.0.0.1:8080", DEFAULT_LOCALHOST_PATTERNS
+ )
+
+ # Non-localhost should be rejected
+ assert not validate_redirect_uri(
+ "http://example.com", DEFAULT_LOCALHOST_PATTERNS
+ )
+ assert not validate_redirect_uri(
+ "https://claude.ai/api/mcp/auth_callback", DEFAULT_LOCALHOST_PATTERNS
+ )
diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py
index 9217bb485..d0118f219 100644
--- a/tests/server/middleware/test_logging.py
+++ b/tests/server/middleware/test_logging.py
@@ -1,30 +1,58 @@
"""Tests for logging middleware."""
+import datetime
import json
import logging
+from typing import Any, Literal, TypeVar
from unittest.mock import AsyncMock, MagicMock
+import mcp
import pytest
+from inline_snapshot import snapshot
+from pydantic import AnyUrl
+from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.middleware.logging import (
LoggingMiddleware,
StructuredLoggingMiddleware,
)
-from fastmcp.server.middleware.middleware import MiddlewareContext
+from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext
+from fastmcp.server.server import FastMCP
+
+FIXED_DATE = datetime.datetime(2023, 1, 1, tzinfo=datetime.timezone.utc)
+
+T = TypeVar("T")
+
+
+def new_mock_context(
+ message: T,
+ method: str | None = None,
+ source: Literal["server", "client"] | None = None,
+ type: Literal["request", "notification"] | None = None,
+) -> MiddlewareContext[T]:
+ """Create a new mock middleware context."""
+ context = MagicMock(spec=MiddlewareContext[T])
+ context.method = method or "test_method"
+ context.source = source or "client"
+ context.type = type or "request"
+ context.message = message
+ context.timestamp = FIXED_DATE
+ return context
@pytest.fixture
def mock_context():
"""Create a mock middleware context."""
- context = MagicMock(spec=MiddlewareContext)
- context.method = "test_method"
- context.source = "client"
- context.type = "request"
- context.message = MagicMock()
- context.message.__dict__ = {"param": "value"}
- context.timestamp = MagicMock()
- context.timestamp.isoformat.return_value = "2023-01-01T00:00:00Z"
- return context
+
+ return new_mock_context(
+ message=mcp.types.CallToolRequest(
+ method="tools/call",
+ params=mcp.types.CallToolRequestParams(
+ name="test_method",
+ arguments={"param": "value"},
+ ),
+ )
+ )
@pytest.fixture
@@ -58,7 +86,9 @@ class TestLoggingMiddleware:
assert middleware.include_payloads is True
assert middleware.max_payload_length == 500
- def test_format_message_without_payloads(self, mock_context):
+ def test_format_message_without_payloads(
+ self, mock_context: MiddlewareContext[Any]
+ ):
"""Test message formatting without payloads."""
middleware = LoggingMiddleware()
formatted = middleware._format_message(mock_context)
@@ -68,17 +98,16 @@ class TestLoggingMiddleware:
assert "method=test_method" in formatted
assert "payload=" not in formatted
- def test_format_message_with_payloads(self, mock_context):
+ def test_format_message_with_payloads(self, mock_context: MiddlewareContext[Any]):
"""Test message formatting with payloads."""
middleware = LoggingMiddleware(include_payloads=True)
formatted = middleware._format_message(mock_context)
- assert "source=client" in formatted
- assert "type=request" in formatted
- assert "method=test_method" in formatted
- assert 'payload={"param": "value"}' in formatted
+ assert formatted == snapshot(
+ 'source=client type=request method=test_method payload={"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"param":"value"}}}'
+ )
- def test_format_message_long_payload(self, mock_context):
+ def test_format_message_long_payload(self, mock_context: MiddlewareContext[Any]):
"""Test message formatting with long payload truncation."""
middleware = LoggingMiddleware(include_payloads=True, max_payload_length=10)
formatted = middleware._format_message(mock_context)
@@ -86,7 +115,12 @@ class TestLoggingMiddleware:
assert "payload=" in formatted
assert "..." in formatted
- async def test_on_message_success(self, mock_context, mock_call_next, caplog):
+ async def test_on_message_success(
+ self,
+ mock_context: MiddlewareContext[Any],
+ mock_call_next: CallNext[Any, Any],
+ caplog: pytest.LogCaptureFixture,
+ ):
"""Test logging successful messages."""
middleware = LoggingMiddleware()
@@ -98,7 +132,9 @@ class TestLoggingMiddleware:
assert "Processing message:" in caplog.text
assert "Completed message: test_method" in caplog.text
- async def test_on_message_failure(self, mock_context, caplog):
+ async def test_on_message_failure(
+ self, mock_context: MiddlewareContext[Any], caplog: pytest.LogCaptureFixture
+ ):
"""Test logging failed messages."""
middleware = LoggingMiddleware()
mock_call_next = AsyncMock(side_effect=ValueError("test error"))
@@ -121,26 +157,40 @@ class TestStructuredLoggingMiddleware:
assert middleware.log_level == logging.INFO
assert middleware.include_payloads is False
- def test_create_log_entry_basic(self, mock_context):
+ def test_create_log_entry_basic(self, mock_context: MiddlewareContext[Any]):
"""Test creating basic log entry."""
middleware = StructuredLoggingMiddleware()
entry = middleware._create_log_entry(mock_context, "test_event")
- assert entry["event"] == "test_event"
- assert entry["timestamp"] == "2023-01-01T00:00:00Z"
- assert entry["source"] == "client"
- assert entry["type"] == "request"
- assert entry["method"] == "test_method"
- assert "payload" not in entry
+ assert entry == snapshot(
+ {
+ "event": "test_event",
+ "timestamp": "2023-01-01T00:00:00+00:00",
+ "source": "client",
+ "type": "request",
+ "method": "test_method",
+ }
+ )
- def test_create_log_entry_with_payload(self, mock_context):
+ def test_create_log_entry_with_payload(self, mock_context: MiddlewareContext[Any]):
"""Test creating log entry with payload."""
middleware = StructuredLoggingMiddleware(include_payloads=True)
entry = middleware._create_log_entry(mock_context, "test_event")
- assert entry["payload"] == {"param": "value"}
+ assert entry == snapshot(
+ {
+ "event": "test_event",
+ "timestamp": "2023-01-01T00:00:00+00:00",
+ "source": "client",
+ "type": "request",
+ "method": "test_method",
+ "payload": '{"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"param":"value"}}}',
+ }
+ )
- def test_create_log_entry_with_extra_fields(self, mock_context):
+ def test_create_log_entry_with_extra_fields(
+ self, mock_context: MiddlewareContext[Any]
+ ):
"""Test creating log entry with extra fields."""
middleware = StructuredLoggingMiddleware()
entry = middleware._create_log_entry(
@@ -149,7 +199,12 @@ class TestStructuredLoggingMiddleware:
assert entry["extra_field"] == "extra_value"
- async def test_on_message_success(self, mock_context, mock_call_next, caplog):
+ async def test_on_message_success(
+ self,
+ mock_context: MiddlewareContext[Any],
+ mock_call_next: CallNext[Any, Any],
+ caplog: pytest.LogCaptureFixture,
+ ):
"""Test structured logging of successful messages."""
middleware = StructuredLoggingMiddleware()
@@ -160,17 +215,33 @@ class TestStructuredLoggingMiddleware:
# Check that we have structured JSON logs
log_lines = [record.message for record in caplog.records]
+
assert len(log_lines) == 2 # start and success entries
- start_entry = json.loads(log_lines[0])
- assert start_entry["event"] == "request_start"
- assert start_entry["method"] == "test_method"
+ assert json.loads(log_lines[0]) == snapshot(
+ {
+ "event": "request_start",
+ "timestamp": "2023-01-01T00:00:00+00:00",
+ "source": "client",
+ "type": "request",
+ "method": "test_method",
+ }
+ )
- success_entry = json.loads(log_lines[1])
- assert success_entry["event"] == "request_success"
- assert success_entry["result_type"] == "str"
+ assert json.loads(log_lines[1]) == snapshot(
+ {
+ "event": "request_success",
+ "timestamp": "2023-01-01T00:00:00+00:00",
+ "source": "client",
+ "type": "request",
+ "method": "test_method",
+ "result_type": "str",
+ }
+ )
- async def test_on_message_failure(self, mock_context, caplog):
+ async def test_on_message_failure(
+ self, mock_context: MiddlewareContext[Any], caplog: pytest.LogCaptureFixture
+ ):
"""Test structured logging of failed messages."""
middleware = StructuredLoggingMiddleware()
mock_call_next = AsyncMock(side_effect=ValueError("test error"))
@@ -186,10 +257,180 @@ class TestStructuredLoggingMiddleware:
start_entry = json.loads(log_lines[0])
assert start_entry["event"] == "request_start"
- error_entry = json.loads(log_lines[1])
- assert error_entry["event"] == "request_error"
- assert error_entry["error_type"] == "ValueError"
- assert error_entry["error_message"] == "test error"
+ assert json.loads(log_lines[1]) == snapshot(
+ {
+ "event": "request_error",
+ "timestamp": "2023-01-01T00:00:00+00:00",
+ "source": "client",
+ "type": "request",
+ "method": "test_method",
+ "error_type": "ValueError",
+ "error_message": "test error",
+ }
+ )
+
+ async def test_on_message_with_pydantic_types_in_payload(
+ self,
+ mock_call_next: CallNext[Any, Any],
+ caplog: pytest.LogCaptureFixture,
+ ):
+ """Ensure Pydantic AnyUrl in payload serializes correctly when include_payloads=True."""
+
+ mock_context = new_mock_context(
+ message=mcp.types.ReadResourceRequest(
+ method="resources/read",
+ params=mcp.types.ReadResourceRequestParams(
+ uri=AnyUrl("test://example/1"),
+ ),
+ )
+ )
+
+ middleware = StructuredLoggingMiddleware(include_payloads=True)
+
+ with caplog.at_level(logging.INFO):
+ result = await middleware.on_message(mock_context, mock_call_next)
+
+ assert result == "test_result"
+
+ log_lines = [record.message for record in caplog.records]
+
+ assert len(log_lines) == 2
+ assert json.loads(log_lines[0]) == snapshot(
+ {
+ "event": "request_start",
+ "timestamp": "2023-01-01T00:00:00+00:00",
+ "source": "client",
+ "type": "request",
+ "method": "test_method",
+ "payload": '{"method":"resources/read","params":{"_meta":null,"uri":"test://example/1"}}',
+ }
+ )
+ assert json.loads(log_lines[1]) == snapshot(
+ {
+ "event": "request_success",
+ "timestamp": "2023-01-01T00:00:00+00:00",
+ "source": "client",
+ "type": "request",
+ "method": "test_method",
+ "result_type": "str",
+ "payload": '{"method":"resources/read","params":{"_meta":null,"uri":"test://example/1"}}',
+ }
+ )
+
+ async def test_on_message_with_resource_template_in_payload(
+ self,
+ mock_call_next: CallNext[Any, Any],
+ caplog: pytest.LogCaptureFixture,
+ ):
+ """Ensure ResourceTemplate in payload serializes via pydantic conversion without errors."""
+
+ mock_context = new_mock_context(
+ message=ResourceTemplate(
+ name="tmpl",
+ uri_template="tmpl://{id}",
+ parameters={"id": {"type": "string"}},
+ )
+ )
+
+ middleware = StructuredLoggingMiddleware(include_payloads=True)
+
+ with caplog.at_level(logging.INFO):
+ result = await middleware.on_message(mock_context, mock_call_next)
+
+ assert result == "test_result"
+
+ log_lines = [record.message for record in caplog.records]
+ assert len(log_lines) == 2
+ assert json.loads(log_lines[0]) == snapshot(
+ {
+ "event": "request_start",
+ "timestamp": "2023-01-01T00:00:00+00:00",
+ "source": "client",
+ "type": "request",
+ "method": "test_method",
+ "payload": '{"name":"tmpl","title":null,"description":null,"tags":[],"meta":null,"enabled":true,"uri_template":"tmpl://{id}","mime_type":"text/plain","parameters":{"id":{"type":"string"}},"annotations":null}',
+ }
+ )
+
+ async def test_on_message_with_nonserializable_payload_falls_back_to_str(
+ self, mock_call_next: CallNext[Any, Any], caplog: pytest.LogCaptureFixture
+ ):
+ """Ensure non-JSONable objects fall back to string serialization in payload."""
+
+ class NonSerializable:
+ def __str__(self) -> str:
+ return "NON_SERIALIZABLE"
+
+ mock_context = new_mock_context(
+ message=mcp.types.CallToolRequest(
+ method="tools/call",
+ params=mcp.types.CallToolRequestParams(
+ name="test_method",
+ arguments={"obj": NonSerializable()},
+ ),
+ )
+ )
+
+ middleware = StructuredLoggingMiddleware(include_payloads=True)
+
+ with caplog.at_level(logging.INFO):
+ result = await middleware.on_message(mock_context, mock_call_next)
+
+ assert result == "test_result"
+
+ log_lines = [record.message for record in caplog.records]
+ assert len(log_lines) >= 2
+ assert json.loads(log_lines[0]) == snapshot(
+ {
+ "event": "request_start",
+ "timestamp": "2023-01-01T00:00:00+00:00",
+ "source": "client",
+ "type": "request",
+ "method": "test_method",
+ "payload": '{"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"obj":"NON_SERIALIZABLE"}}}',
+ }
+ )
+
+ async def test_on_message_with_custom_serializer_applied(
+ self, mock_call_next: CallNext[Any, Any], caplog: pytest.LogCaptureFixture
+ ):
+ """Ensure a custom serializer is used for non-JSONable payloads."""
+
+ # Provide a serializer that replaces entire payload with a fixed string
+ def custom_serializer(_: Any) -> str:
+ return "CUSTOM_PAYLOAD"
+
+ mock_context = new_mock_context(
+ message=mcp.types.CallToolRequest(
+ method="tools/call",
+ params=mcp.types.CallToolRequestParams(
+ name="test_method",
+ arguments={"obj": "OBJECT"},
+ ),
+ )
+ )
+
+ middleware = StructuredLoggingMiddleware(
+ include_payloads=True, payload_serializer=custom_serializer
+ )
+
+ with caplog.at_level(logging.INFO):
+ result = await middleware.on_message(mock_context, mock_call_next)
+
+ assert result == "test_result"
+
+ log_lines = [record.message for record in caplog.records]
+ assert len(log_lines) >= 2
+ assert json.loads(log_lines[0]) == snapshot(
+ {
+ "event": "request_start",
+ "timestamp": "2023-01-01T00:00:00+00:00",
+ "source": "client",
+ "type": "request",
+ "method": "test_method",
+ "payload": "CUSTOM_PAYLOAD",
+ }
+ )
@pytest.fixture
@@ -233,7 +474,7 @@ class TestLoggingMiddlewareIntegration:
"""Integration tests for logging middleware with real FastMCP server."""
async def test_logging_middleware_logs_successful_operations(
- self, logging_server, caplog
+ self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture
):
"""Test that logging middleware captures successful operations."""
from fastmcp.client import Client
@@ -259,7 +500,9 @@ class TestLoggingMiddlewareIntegration:
assert processing_count == 2
assert completion_count == 2
- async def test_logging_middleware_logs_failures(self, logging_server, caplog):
+ async def test_logging_middleware_logs_failures(
+ self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture
+ ):
"""Test that logging middleware captures failed operations."""
from fastmcp.client import Client
@@ -279,7 +522,9 @@ class TestLoggingMiddlewareIntegration:
assert "Processing message:" in log_text
assert "Failed message: tools/call" in log_text
- async def test_logging_middleware_with_payloads(self, logging_server, caplog):
+ async def test_logging_middleware_with_payloads(
+ self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture
+ ):
"""Test logging middleware when configured to include payloads."""
from fastmcp.client import Client
@@ -300,7 +545,7 @@ class TestLoggingMiddlewareIntegration:
assert "payload=" in log_text
async def test_structured_logging_middleware_produces_json(
- self, logging_server, caplog
+ self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture
):
"""Test that structured logging middleware produces parseable JSON logs."""
import json
@@ -334,7 +579,7 @@ class TestLoggingMiddlewareIntegration:
assert "method" in log_entry
async def test_structured_logging_middleware_handles_errors(
- self, logging_server, caplog
+ self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture
):
"""Test structured logging of errors with JSON format."""
import json
@@ -375,7 +620,7 @@ class TestLoggingMiddlewareIntegration:
assert "error_message" in error_entry
async def test_logging_middleware_with_different_operations(
- self, logging_server, caplog
+ self, logging_server: FastMCP, caplog: pytest.LogCaptureFixture
):
"""Test logging middleware with various MCP operations."""
from fastmcp.client import Client
@@ -410,7 +655,9 @@ class TestLoggingMiddlewareIntegration:
assert processing_count == 4
assert completion_count == 4
- async def test_logging_middleware_custom_configuration(self, logging_server):
+ async def test_logging_middleware_custom_configuration(
+ self, logging_server: FastMCP
+ ):
"""Test logging middleware with custom logger configuration."""
import io
import logging
diff --git a/tests/utilities/test_cli.py b/tests/utilities/test_cli.py
index bcb45f664..afe17deda 100644
--- a/tests/utilities/test_cli.py
+++ b/tests/utilities/test_cli.py
@@ -1,24 +1,26 @@
"""Tests for CLI utility functions."""
-from fastmcp.utilities.fastmcp_config.v1.fastmcp_config import Environment
+from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
-class TestEnvironmentBuildUVArgs:
- """Test the Environment.build_uv_args() method."""
+class TestEnvironmentBuildUVRunCommand:
+ """Test the Environment.build_uv_run_command() method."""
- def test_build_uv_args_basic(self):
- """Test building basic uv args with no environment config."""
- env = Environment()
- args = env.build_uv_args(["fastmcp", "run", "server.py"])
- expected = ["run", "fastmcp", "run", "server.py"]
- assert args == expected
+ def test_build_uv_run_command_basic(self):
+ """Test building basic uv command with no environment config."""
+ env = UVEnvironment()
+ cmd = env.build_command(["fastmcp", "run", "server.py"])
+ # With no config, the command should be returned unchanged
+ expected = ["fastmcp", "run", "server.py"]
+ assert cmd == expected
- def test_build_uv_args_with_editable(self):
- """Test building uv args with editable package."""
+ def test_build_uv_run_command_with_editable(self):
+ """Test building uv command with editable package."""
editable_path = "/path/to/package"
- env = Environment(editable=[editable_path])
- args = env.build_uv_args(["fastmcp", "run", "server.py"])
+ env = UVEnvironment(editable=[editable_path])
+ cmd = env.build_command(["fastmcp", "run", "server.py"])
expected = [
+ "uv",
"run",
"--with-editable",
editable_path,
@@ -26,13 +28,14 @@ class TestEnvironmentBuildUVArgs:
"run",
"server.py",
]
- assert args == expected
+ assert cmd == expected
- 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"])
+ def test_build_uv_run_command_with_packages(self):
+ """Test building uv command with additional packages."""
+ env = UVEnvironment(dependencies=["pkg1", "pkg2"])
+ cmd = env.build_command(["fastmcp", "run", "server.py"])
expected = [
+ "uv",
"run",
"--with",
"pkg1",
@@ -42,13 +45,14 @@ class TestEnvironmentBuildUVArgs:
"run",
"server.py",
]
- assert args == expected
+ assert cmd == expected
- def test_build_uv_args_with_python_version(self):
- """Test building uv args with Python version."""
- env = Environment(python="3.10")
- args = env.build_uv_args(["fastmcp", "run", "server.py"])
+ def test_build_uv_run_command_with_python_version(self):
+ """Test building uv command with Python version."""
+ env = UVEnvironment(python="3.10")
+ cmd = env.build_command(["fastmcp", "run", "server.py"])
expected = [
+ "uv",
"run",
"--python",
"3.10",
@@ -56,14 +60,15 @@ class TestEnvironmentBuildUVArgs:
"run",
"server.py",
]
- assert args == expected
+ assert cmd == expected
- def test_build_uv_args_with_requirements(self):
- """Test building uv args with requirements file."""
+ def test_build_uv_run_command_with_requirements(self):
+ """Test building uv command with requirements file."""
requirements_path = "/path/to/requirements.txt"
- env = Environment(requirements=requirements_path)
- args = env.build_uv_args(["fastmcp", "run", "server.py"])
+ env = UVEnvironment(requirements=requirements_path)
+ cmd = env.build_command(["fastmcp", "run", "server.py"])
expected = [
+ "uv",
"run",
"--with-requirements",
requirements_path,
@@ -71,28 +76,37 @@ class TestEnvironmentBuildUVArgs:
"run",
"server.py",
]
- assert args == expected
+ assert cmd == expected
- def test_build_uv_args_with_project(self):
- """Test building uv args with project directory."""
+ def test_build_uv_run_command_with_project(self):
+ """Test building uv command with project directory."""
project_path = "/path/to/project"
- env = Environment(project=project_path)
- args = env.build_uv_args(["fastmcp", "run", "server.py"])
- expected = ["run", "--project", project_path, "fastmcp", "run", "server.py"]
- assert args == expected
+ env = UVEnvironment(project=project_path)
+ cmd = env.build_command(["fastmcp", "run", "server.py"])
+ expected = [
+ "uv",
+ "run",
+ "--project",
+ project_path,
+ "fastmcp",
+ "run",
+ "server.py",
+ ]
+ assert cmd == expected
- def test_build_uv_args_with_everything(self):
- """Test building uv args with all options."""
+ def test_build_uv_run_command_with_everything(self):
+ """Test building uv command with all options."""
requirements_path = "/path/to/requirements.txt"
editable_path = "/local/pkg"
- env = Environment(
+ env = UVEnvironment(
python="3.10",
dependencies=["pandas", "numpy"],
requirements=requirements_path,
editable=[editable_path],
)
- args = env.build_uv_args(["fastmcp", "run", "server.py"])
+ cmd = env.build_command(["fastmcp", "run", "server.py"])
expected = [
+ "uv",
"run",
"--python",
"3.10",
@@ -108,33 +122,23 @@ class TestEnvironmentBuildUVArgs:
"run",
"server.py",
]
- assert args == expected
+ assert cmd == expected
- def test_build_uv_args_no_command(self):
- """Test building uv args without command."""
- env = Environment(dependencies=["pkg1"])
- args = env.build_uv_args()
- expected = ["run", "--with", "pkg1"]
- assert args == expected
+ # Note: These tests are removed because build_uv_run_command now requires a command
+ # and only accepts a list, not optional or string commands
- def test_build_uv_args_with_string_command(self):
- """Test building uv args with string command."""
- env = Environment()
- args = env.build_uv_args("python")
- expected = ["run", "python"]
- assert args == expected
-
- def test_build_uv_args_project_with_extras(self):
+ def test_build_uv_run_command_project_with_extras(self):
"""Test that project flag works with additional dependencies."""
project_path = "/path/to/project"
- env = Environment(
+ env = UVEnvironment(
project=project_path,
python="3.10", # Should be ignored with project
dependencies=["pandas"], # Should be added on top of project
editable=["/pkg"], # Should be added on top of project
)
- args = env.build_uv_args(["fastmcp", "run", "server.py"])
+ cmd = env.build_command(["fastmcp", "run", "server.py"])
expected = [
+ "uv",
"run",
"--project",
project_path,
@@ -146,7 +150,7 @@ class TestEnvironmentBuildUVArgs:
"run",
"server.py",
]
- assert args == expected
+ assert cmd == expected
class TestEnvironmentNeedsUV:
@@ -154,35 +158,35 @@ class TestEnvironmentNeedsUV:
def test_needs_uv_with_python(self):
"""Test that needs_uv returns True with Python version."""
- env = Environment(python="3.10")
+ env = UVEnvironment(python="3.10")
assert env.needs_uv() is True
def test_needs_uv_with_dependencies(self):
"""Test that needs_uv returns True with dependencies."""
- env = Environment(dependencies=["pandas"])
+ env = UVEnvironment(dependencies=["pandas"])
assert env.needs_uv() is True
def test_needs_uv_with_requirements(self):
"""Test that needs_uv returns True with requirements."""
- env = Environment(requirements="/path/to/requirements.txt")
+ env = UVEnvironment(requirements="/path/to/requirements.txt")
assert env.needs_uv() is True
def test_needs_uv_with_project(self):
"""Test that needs_uv returns True with project."""
- env = Environment(project="/path/to/project")
+ env = UVEnvironment(project="/path/to/project")
assert env.needs_uv() is True
def test_needs_uv_with_editable(self):
"""Test that needs_uv returns True with editable."""
- env = Environment(editable=["/pkg"])
+ env = UVEnvironment(editable=["/pkg"])
assert env.needs_uv() is True
def test_needs_uv_empty(self):
"""Test that needs_uv returns False with empty config."""
- env = Environment()
+ env = UVEnvironment()
assert env.needs_uv() is False
def test_needs_uv_with_empty_lists(self):
"""Test that needs_uv returns False with empty lists."""
- env = Environment(dependencies=None, editable=None)
+ env = UVEnvironment(dependencies=None, editable=None)
assert env.needs_uv() is False