Merge branch 'main' into claude/issue-1647-20250827-0442

This commit is contained in:
William Easton 2025-09-01 14:02:32 -05:00 committed by GitHub
commit d8a921345c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 2349 additions and 1550 deletions

View file

@ -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>"

View file

@ -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>"

View file

@ -4,6 +4,60 @@ icon: "list-check"
rss: true
---
<Update label="v2.12.0" description="2025-08-31">
**[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)
</Update>
<Update label="v2.11.3" description="2025-08-11">
**[v2.11.3: API-tite for Change](https://github.com/jlowin/fastmcp/releases/tag/v2.11.3)**

View file

@ -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.
<Card icon="code" title="Environment Fields">
<Card icon="code" title="Environment">
<ParamField body="environment" type="object">
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.
<Expandable title="Environment Fields">
<ParamField body="type" type="string" default="uv">
The environment type identifier that determines which implementation to use. Currently supports `"uv"` for Python environments managed by uv. If omitted, defaults to `"uv"`.
</ParamField>
<Expandable title="UVEnvironment">
When `type` is `"uv"` (or omitted), the environment uses uv to manage Python dependencies:
<ParamField body="python" type="string">
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"]
```
</ParamField>
**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.
</Expandable>
</ParamField>
</Card>
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.
<Note>
**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.
</Note>
### 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": "."

View file

@ -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()

View file

@ -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"

View file

@ -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": {

View file

@ -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": {

View file

@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts.
## Functions
### `with_argv` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L70" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `with_argv` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L68" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L93" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `version` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L91" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
version()
@ -37,7 +37,7 @@ version()
Display version information and platform details.
### `dev` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L131" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `dev` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L129" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L349" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `run` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L310" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L622" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `inspect` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L524" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L904" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `prepare` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/cli.py#L765" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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

View file

@ -57,7 +57,7 @@ Install FastMCP server in Claude Code.
- True if installation was successful, False otherwise
### `claude_code_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/install/claude_code.py#L163" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `claude_code_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/install/claude_code.py#L162" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
claude_code_command(server_spec: str) -> None

View file

@ -44,7 +44,7 @@ Install FastMCP server in Claude Desktop.
- True if installation was successful, False otherwise
### `claude_desktop_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/install/claude_desktop.py#L135" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `claude_desktop_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/install/claude_desktop.py#L133" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
claude_desktop_command(server_spec: str) -> None

View file

@ -68,7 +68,7 @@ Install FastMCP server to workspace-specific Cursor configuration.
- True if installation was successful, False otherwise
### `install_cursor` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/install/cursor.py#L159" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `install_cursor` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/install/cursor.py#L157" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/install/cursor.py#L254" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `cursor_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/install/cursor.py#L250" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
cursor_command(server_spec: str) -> None

View file

@ -35,7 +35,7 @@ Generate MCP configuration JSON for manual installation.
- True if generation was successful, False otherwise
### `mcp_json_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/install/mcp_json.py#L108" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `mcp_json_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/install/mcp_json.py#L106" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
mcp_json_command(server_spec: str) -> None

View file

@ -10,7 +10,7 @@ FastMCP run command implementation with enhanced type hints.
## Functions
### `is_url` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L29" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `is_url` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L28" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L35" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `run_with_uv` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L34" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L170" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `create_client_server` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L115" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L190" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `create_mcp_config_server` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L135" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L201" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `load_mcp_server_config` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L146" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L219" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `run_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L163" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L341" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `run_v1_server` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/cli/run.py#L284" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
run_v1_server(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None

View file

@ -7,13 +7,13 @@ sidebarTitle: oauth
## Functions
### `default_cache_dir` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L36" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `default_cache_dir` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L48" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
default_cache_dir() -> Path
```
### `check_if_auth_required` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L154" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `check_if_auth_required` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L192" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L40" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `StoredToken` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L37" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Token storage format with absolute expiry time.
### `FileTokenStorage` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L52" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L55" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_base_url` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L67" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_cache_key` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L72" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L75" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_tokens` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L87" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_tokens(self) -> OAuthToken | None
@ -66,7 +72,7 @@ get_tokens(self) -> OAuthToken | None
Load tokens from file storage.
#### `set_tokens` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L92" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_tokens` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L119" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L98" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_client_info` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L136" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L126" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_client_info` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L164" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L132" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `clear` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L170" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
clear(self) -> None
@ -102,7 +108,7 @@ clear(self) -> None
Clear all cached data for this server.
#### `clear_all` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L141" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `clear_all` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L179" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L184" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `OAuth` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L222" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L255" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `redirect_handler` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L302" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L260" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `callback_handler` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/auth/oauth.py#L307" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
callback_handler(self) -> tuple[str, str | None]

View file

@ -7,7 +7,7 @@ sidebarTitle: client
## Classes
### `ClientSessionState` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L80" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `ClientSessionState` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L81" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L96" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `Client` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L97" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
MCP client that delegates connection management to a Transport instance.
@ -79,7 +79,7 @@ async with client:
**Methods:**
#### `session` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L282" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `session` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L283" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
session(self) -> ClientSession
@ -88,7 +88,7 @@ session(self) -> ClientSession
Get the current active session. Raises RuntimeError if not connected.
#### `initialize_result` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L292" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `initialize_result` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L293" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L300" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_roots` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L301" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L304" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_sampling_callback` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L305" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L310" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_elicitation_callback` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L311" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L318" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `is_connected` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L319" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_connected(self) -> bool
@ -133,7 +133,7 @@ is_connected(self) -> bool
Check if the client is currently connected.
#### `new` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L322" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `new` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L323" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L487" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `close` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L490" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
close(self)
```
#### `ping` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L493" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `ping` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L496" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
ping(self) -> bool
@ -164,7 +164,7 @@ ping(self) -> bool
Send a ping request.
#### `cancel` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L498" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `cancel` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L501" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L515" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `progress` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L518" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L527" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `set_logging_level` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L530" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L531" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `send_roots_list_changed` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L534" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L537" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `list_resources_mcp` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L540" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L552" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `list_resources` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L555" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L564" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `list_resource_templates_mcp` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L567" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L581" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `list_resource_templates` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L584" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L595" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `read_resource_mcp` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L598" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L617" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `read_resource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L620" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L656" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `list_prompts_mcp` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L659" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L671" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `list_prompts` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L674" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L684" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_prompt_mcp` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L687" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L720" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_prompt` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L723" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L741" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `complete_mcp` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L744" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L764" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `complete` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L767" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L786" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `list_tools_mcp` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L789" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L801" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `list_tools` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L804" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L815" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `call_tool_mcp` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L818" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L852" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `call_tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L855" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L923" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `generate_name` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L926" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
generate_name(cls, name: str | None = None) -> str
```
### `CallToolResult` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L932" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `CallToolResult` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/client/client.py#L935" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>

View file

@ -182,7 +182,7 @@ Handles provider-specific requirements:
**Methods:**
#### `get_client` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L354" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_client` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L330" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L386" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `register_client` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L363" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L435" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `authorize` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L422" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L491" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `load_authorization_code` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L478" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L533" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `exchange_authorization_code` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L520" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L600" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `load_refresh_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L587" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L608" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `exchange_refresh_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L595" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L683" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `load_access_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L670" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L700" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `revoke_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L687" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L893" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/oauth_proxy.py#L880" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_routes(self) -> list[Route]

View file

@ -20,7 +20,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows.
Settings for Azure OAuth provider.
### `AzureTokenVerifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/azure.py#L46" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `AzureTokenVerifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/azure.py#L48" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Token verifier for Azure OAuth tokens.
@ -31,7 +31,7 @@ to get user information and validate the token.
**Methods:**
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/azure.py#L68" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/azure.py#L70" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/azure.py#L118" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `AzureProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/azure.py#L120" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Azure (Microsoft Entra) OAuth provider for FastMCP.

View file

@ -35,7 +35,7 @@ Example:
Settings for GitHub OAuth provider.
### `GitHubTokenVerifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/github.py#L61" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `GitHubTokenVerifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/github.py#L63" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/github.py#L83" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/github.py#L85" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/github.py#L167" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `GitHubProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/github.py#L169" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Complete GitHub OAuth provider for FastMCP.

View file

@ -35,7 +35,7 @@ Example:
Settings for Google OAuth provider.
### `GoogleTokenVerifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/google.py#L63" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `GoogleTokenVerifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/google.py#L65" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/google.py#L85" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/google.py#L87" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/google.py#L183" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `GoogleProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/google.py#L185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Complete Google OAuth provider for FastMCP.

View file

@ -24,7 +24,7 @@ Choose based on your WorkOS setup and authentication requirements.
Settings for WorkOS OAuth provider.
### `WorkOSTokenVerifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L53" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `WorkOSTokenVerifier` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L55" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Token verifier for WorkOS OAuth tokens.
@ -35,7 +35,7 @@ the /oauth2/userinfo endpoint to check validity and get user info.
**Methods:**
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L78" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `verify_token` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L80" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L126" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `WorkOSProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L128" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Complete WorkOS OAuth provider for FastMCP.
@ -65,9 +65,9 @@ Setup Requirements:
4. Note your Client ID and Client Secret
### `AuthKitProviderSettings` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L253" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `AuthKitProviderSettings` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L267" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `AuthKitProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L271" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `AuthKitProvider` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L285" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
AuthKit metadata provider for DCR (Dynamic Client Registration).
@ -93,7 +93,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification
**Methods:**
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L354" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_routes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/providers/workos.py#L368" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_routes(self) -> list[Route]

View file

@ -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

View file

@ -8,9 +8,21 @@ sidebarTitle: logging
Comprehensive logging middleware for FastMCP servers.
## Functions
### `default_serializer` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/logging.py#L14" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
default_serializer(data: Any) -> str
```
The default serializer for Payloads in the logging middleware.
## Classes
### `LoggingMiddleware` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/logging.py#L10" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `LoggingMiddleware` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/logging.py#L19" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Middleware that provides comprehensive request and response logging.
@ -21,16 +33,16 @@ monitoring, and understanding server usage patterns.
**Methods:**
#### `on_message` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/logging.py#L71" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `on_message` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/logging.py#L91" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/logging.py#L92" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `StructuredLoggingMiddleware` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/logging.py#L114" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/logging.py#L149" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `on_message` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/middleware/logging.py#L185" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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.

View file

@ -7,7 +7,37 @@ sidebarTitle: cli
## Functions
### `log_server_banner` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/cli.py#L27" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `is_already_in_uv_subprocess` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/cli.py#L28" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
is_already_in_uv_subprocess() -> bool
```
Check if we're already running in a FastMCP uv subprocess.
### `load_and_merge_config` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/cli.py#L33" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/cli.py#L151" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
log_server_banner(server: FastMCP[Any], transport: Literal['stdio', 'http', 'sse', 'streamable-http']) -> None

View file

@ -3,7 +3,7 @@ title: __init__
sidebarTitle: __init__
---
# `fastmcp.utilities.fastmcp_config`
# `fastmcp.utilities.mcp_server_config`
FastMCP Configuration module.

View file

@ -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.*

View file

@ -0,0 +1,9 @@
---
title: __init__
sidebarTitle: __init__
---
# `fastmcp.utilities.mcp_server_config.v1.environments`
Environment configuration for MCP servers.

View file

@ -0,0 +1,43 @@
---
title: base
sidebarTitle: base
---
# `fastmcp.utilities.mcp_server_config.v1.environments.base`
## Classes
### `Environment` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py#L7" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Base class for environment configuration.
**Methods:**
#### `build_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py#L13" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py#L24" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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

View file

@ -0,0 +1,75 @@
---
title: uv
sidebarTitle: uv
---
# `fastmcp.utilities.mcp_server_config.v1.environments.uv`
## Classes
### `UVEnvironment` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py#L16" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Configuration for Python environment setup.
**Methods:**
#### `build_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py#L51" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py#L95" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py#L136" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
needs_uv(self) -> bool
```
Deprecated: Use _needs_setup() internally or check if build_command modifies the command.
#### `build_uv_run_command` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py#L140" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
build_uv_run_command(self, command: list[str]) -> list[str]
```
Deprecated: Use build_command() instead.
#### `prepare` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py#L144" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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.

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L707" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `generate_schema` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L415" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | None
@ -38,67 +38,7 @@ validation and auto-completion.
## Classes
### `Environment` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L33" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Configuration for Python environment setup.
**Methods:**
#### `build_uv_args` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L66" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L109" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L132" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L148" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L313" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `Deployment` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L36" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Configuration for server deployment and runtime settings.
@ -106,7 +46,7 @@ Configuration for server deployment and runtime settings.
**Methods:**
#### `apply_runtime_settings` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L362" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `apply_runtime_settings` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L85" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L411" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `MCPServerConfig` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L134" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Configuration for a FastMCP server.
@ -133,10 +73,10 @@ a FastMCP server in a declarative format.
**Methods:**
#### `validate_source` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L460" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `validate_source` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L183" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L483" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `validate_environment` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L199" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L499" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `validate_deployment` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L210" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
validate_deployment(cls, v: dict | Deployment) -> Deployment
@ -175,10 +113,10 @@ Accepts:
- dict that can be converted to Deployment
#### `from_file` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L515" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `from_file` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L223" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L538" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `from_cli_args` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L246" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L615" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `find_config` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L323" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L634" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `prepare` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L342" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L655" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `prepare_environment` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L363" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L666" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `prepare_source` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L374" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
prepare_source(self) -> None
@ -283,7 +221,7 @@ Prepare the source for loading.
Delegates to the source's prepare() method.
#### `run_server` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/fastmcp_config.py#L673" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `run_server` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py#L381" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
run_server(self, **kwargs: Any) -> None

View file

@ -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.*

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py#L7" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `Source` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py#L7" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Abstract base class for all source types.
@ -15,7 +15,7 @@ Abstract base class for all source types.
**Methods:**
#### `prepare` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py#L12" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `prepare` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py#L12" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/sources/base.py#L23" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `load_server` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py#L23" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
load_server(self) -> Any

View file

@ -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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py#L15" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `FileSystemSource` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py#L15" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Source for local Python files.
@ -15,7 +15,7 @@ Source for local Python files.
**Methods:**
#### `parse_path_with_object` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py#L27" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `parse_path_with_object` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py#L28" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```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` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/fastmcp_config/v1/sources/filesystem.py#L62" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `load_server` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py#L63" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
load_server(self) -> Any

View file

@ -159,19 +159,46 @@ The `OAuthProxy` class provides the complete proxy implementation:
</ParamField>
<ParamField body="resource_server_url" type="AnyHttpUrl | str | None">
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.
</ParamField>
<ParamField body="allowed_client_redirect_uris" type="list[str] | None">
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.
</ParamField>
</Card>
### 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
<Warning>
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:
<Note>
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=[]
)
```
</Warning>
**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.
</Note>
## Client Compatibility

View file

@ -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]:

View file

@ -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
)

View file

@ -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
)

View file

@ -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"],

View file

@ -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
)

View file

@ -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

View file

@ -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:

View file

@ -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(

View file

@ -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

View file

@ -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 {},
)

View file

@ -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 {},
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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}")

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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

View file

@ -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=<non-serializable>")
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"] = "<non-serializable>"
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:

View file

@ -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"""
_ __ ___ _____ __ __ _____________ ____ ____
_ __ ___ .'____/___ ______/ /_/ |/ / ____/ __ \ |___ \ / __ \

View file

@ -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",
]

View file

@ -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",
]

View file

@ -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"]

View file

@ -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

View file

@ -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}")

View file

@ -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

View file

@ -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": {

View file

@ -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")

View file

@ -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,

View file

@ -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,
)

View file

@ -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"

View file

@ -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]
)

View file

@ -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

View file

@ -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

View file

@ -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,
)

View file

@ -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"

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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
)

View file

@ -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

View file

@ -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