diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml index 03bef97fb..c13eff127 100644 --- a/.github/workflows/auto-close-duplicates.yml +++ b/.github/workflows/auto-close-duplicates.yml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Auto-close duplicate issues run: uv run scripts/auto_close_duplicates.py diff --git a/.github/workflows/martian-issue-triage.yml b/.github/workflows/martian-issue-triage.yml index 6c31237b6..27ed31dcf 100644 --- a/.github/workflows/martian-issue-triage.yml +++ b/.github/workflows/martian-issue-triage.yml @@ -30,7 +30,7 @@ jobs: # Install UV package manager - name: Install UV - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/marvin.yml b/.github/workflows/marvin.yml index 165edb7ad..9566eea11 100644 --- a/.github/workflows/marvin.yml +++ b/.github/workflows/marvin.yml @@ -35,7 +35,7 @@ jobs: # Install UV package manager - name: Install UV - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9bda21fe9..2a7f3adc9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 - name: "Install uv" - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Build run: uv build diff --git a/.github/workflows/run-static.yml b/.github/workflows/run-static.yml index de046d3ba..ad95bcc54 100644 --- a/.github/workflows/run-static.yml +++ b/.github/workflows/run-static.yml @@ -32,7 +32,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 7b9672978..80c39dfd1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -37,7 +37,7 @@ jobs: - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" @@ -62,7 +62,7 @@ jobs: - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml index 6d0d662e6..1ebe92ec1 100644 --- a/.github/workflows/update-config-schema.yml +++ b/.github/workflows/update-config-schema.yml @@ -31,7 +31,7 @@ jobs: private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml index 83ffe7859..8d1996f7a 100644 --- a/.github/workflows/update-sdk-docs.yml +++ b/.github/workflows/update-sdk-docs.yml @@ -31,7 +31,7 @@ jobs: private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/README_OPENAPI.md b/README_OPENAPI.md deleted file mode 100644 index cb0d5f9c0..000000000 --- a/README_OPENAPI.md +++ /dev/null @@ -1,246 +0,0 @@ -# FastMCP OpenAPI Integration - -This document explains how FastMCP's OpenAPI integration works, what features are supported, and how to extend it. The OpenAPI functionality is split across two main files: - -- `server/openapi.py` - High-level FastMCP server implementation and MCP component creation -- `utilities/openapi.py` - Low-level OpenAPI parsing and intermediate representation - -## Architecture Overview - -``` -OpenAPI Spec → Parse → HTTPRoute IR → Create MCP Components → FastMCP Server -``` - -### 1. Parsing Phase (`utilities/openapi.py`) - -OpenAPI specifications are parsed into an intermediate representation (IR) that normalizes differences between OpenAPI 3.0 and 3.1: - -- **Input**: Raw OpenAPI spec (dict) -- **Output**: List of `HTTPRoute` objects with normalized parameter information -- **Key Classes**: - - `HTTPRoute` - Represents a single operation - - `ParameterInfo` - Represents a parameter with location, style, explode, etc. - - `RequestBodyInfo` - Represents request body information - - `ResponseInfo` - Represents response information - -### 2. Component Creation Phase (`server/openapi.py`) - -HTTPRoute objects are converted into FastMCP components based on route mapping rules: - -- **Tools** (`OpenAPITool`) - HTTP operations that can be called -- **Resources** (`OpenAPIResource`) - HTTP endpoints that return data -- **Resource Templates** (`OpenAPIResourceTemplate`) - Parameterized resources - -## Parameter Handling - -FastMCP supports various OpenAPI parameter serialization styles and formats: - -### Supported Parameter Locations -- `query` - Query string parameters -- `path` - Path parameters -- `header` - HTTP headers -- `cookie` - Cookie parameters (parsed but not used in requests) - -### Supported Parameter Styles - -#### Query Parameters -- **`form`** (default) - Standard query parameter format - - `explode=true` (default): `?tags=red&tags=blue` - - `explode=false`: `?tags=red,blue` -- **`deepObject`** - Object parameters with bracket notation - - `explode=true`: `?filter[name]=John&filter[age]=30` - - `explode=false`: Falls back to JSON string (non-standard, logs warning) - -#### Path Parameters -- **`simple`** (default) - Comma-separated for arrays: `/users/1,2,3` - -#### Header Parameters -- **`simple`** (default) - Standard header format - -### Parameter Type Support - -#### Arrays -- String arrays with `explode=true/false` -- Number arrays with `explode=true/false` -- Boolean arrays with `explode=true/false` -- Complex object arrays (basic support, may not handle all cases) - -#### Objects -- Objects with `deepObject` style and `explode=true` -- Objects with other styles fall back to JSON serialization - -#### Primitives -- Strings, numbers, booleans -- Enums -- Default values - -## Request Body Handling - -### Supported Content Types -- `application/json` - JSON request bodies - -### Schema Support -- Object schemas with properties -- Array schemas -- Primitive schemas -- Schema references (`$ref` to local schemas only) -- Required properties -- Default values - -## Response Handling - -### Content Type Detection -- `application/json` - Parsed as JSON -- `text/*` - Returned as text -- `application/xml` - Returned as text -- Other types - Returned as binary - -### Output Schema Generation -- Success response schemas (200, 201, 202, 204) -- Object response wrapping for MCP compliance -- Schema compression (removes unused `$defs`) - -## Route Mapping - -Routes are mapped to MCP component types using `RouteMap` configurations: - -```python -RouteMap( - methods=["GET", "POST"], # HTTP methods to match - pattern=r"/api/users/.*", # Regex pattern for path - mcp_type=MCPType.RESOURCE_TEMPLATE, # Target component type - tags={"user"}, # OpenAPI tags to match (AND condition) - mcp_tags={"fastmcp-user"} # Tags to add to created components -) -``` - -### Default Behavior -- All routes become **Tools** by default -- Use route maps to override specific patterns - -### Component Types -- `MCPType.TOOL` - Callable operations -- `MCPType.RESOURCE` - Static data endpoints -- `MCPType.RESOURCE_TEMPLATE` - Parameterized data endpoints -- `MCPType.EXCLUDE` - Skip route entirely - -## Known Limitations & Edge Cases - -### Parameter Edge Cases -1. **Parameter Name Collisions** - When path/query parameters have same names as request body properties, non-body parameters get `__location` suffixes -2. **Complex Array Serialization** - Limited support for arrays containing objects -3. **Cookie Parameters** - Parsed but not used in requests -4. **Non-standard Combinations** - e.g., `deepObject` with `explode=false` - -### Request Body Edge Cases -1. **Content Type Priority** - Only first available content type is used -2. **Nested Objects** - Deep nesting may not serialize correctly -3. **Binary Content** - No support for file uploads or binary data - -### Response Edge Cases -1. **Multiple Content Types** - Only JSON-compatible types are used for output schemas -2. **Error Responses** - Not used for MCP output schema generation -3. **Response Headers** - Not captured or exposed - -### Schema Edge Cases -1. **External References** - `$ref` to external files not supported -2. **Circular References** - May cause issues in schema processing -3. **Polymorphism** - `oneOf`/`anyOf`/`allOf` limited support - -## Debugging Tips - -### Common Issues -1. **"Unknown tool/resource"** - Check route mapping configuration -2. **Parameter not found** - Check for name collisions or incorrect style/explode -3. **Invalid request format** - Check parameter serialization and content types -4. **Schema validation errors** - Check for external refs or complex schemas - -### Debugging Tools -```python -# Parse routes to inspect intermediate representation -routes = parse_openapi_to_http_routes(openapi_spec) -for route in routes: - print(f"{route.method} {route.path}") - for param in route.parameters: - print(f" {param.name} ({param.location}): style={param.style}, explode={param.explode}") - -# Check component creation -server = FastMCP.from_openapi(openapi_spec, client) -tools = await server.get_tools() -print(f"Created {len(tools)} tools: {list(tools.keys())}") -``` - -### Logging -- Set `FASTMCP_LOG_LEVEL=DEBUG` to see detailed parameter processing -- Look for warnings about non-standard parameter combinations -- Check for schema parsing errors in logs - -## Extension Points - -### Adding New Parameter Styles -1. Add style handling in `utilities/openapi.py` - `ParameterInfo` class -2. Implement serialization logic in `server/openapi.py` - `OpenAPITool.run()` -3. Add tests for parsing and serialization - -### Adding New Content Types -1. Extend request body handling in `OpenAPITool.run()` -2. Add response parsing logic for new types -3. Update content type priority in utilities - -### Custom Route Mapping -Use `route_map_fn` for complex routing logic: - -```python -def custom_mapper(route: HTTPRoute, current_type: MCPType) -> MCPType: - if route.path.startswith("/admin"): - return MCPType.EXCLUDE - return current_type - -server = FastMCP.from_openapi(spec, client, route_map_fn=custom_mapper) -``` - -## Testing Patterns - -### Unit Tests -- Test parameter parsing with various styles/explode combinations -- Test route mapping with different patterns and tags -- Test schema generation and compression - -### Integration Tests -- Mock HTTP client to verify actual request parameters -- Test end-to-end component creation and execution -- Test error handling and edge cases - -### Example Test Pattern -```python -async def test_parameter_style(): - # 1. Create OpenAPI spec with specific parameter configuration - spec = {"openapi": "3.1.0", ...} - - # 2. Parse and create components - routes = parse_openapi_to_http_routes(spec) - tool = OpenAPITool(mock_client, routes[0], ...) - - # 3. Execute and verify request parameters - await tool.run({"param": "value"}) - actual_params = mock_client.request.call_args.kwargs["params"] - assert actual_params == expected_params -``` - -## Testing - -OpenAPI functionality is tested across multiple files in `tests/server/openapi/`: - -- `test_basic_functionality.py` - Core component creation and execution -- `test_explode_integration.py` - Parameter explode behavior -- `test_deepobject_style.py` - DeepObject style parameter encoding -- `test_parameter_collisions.py` - Parameter name collision handling -- `test_openapi_path_parameters.py` - Path parameter serialization -- `test_configuration.py` - Route mapping and MCP names -- `test_description_propagation.py` - Schema and description handling - -When adding new OpenAPI features, create focused test files rather than adding to existing monolithic files. - ---- - -*This document should be updated when new OpenAPI features are added or when edge cases are discovered and addressed.* \ No newline at end of file diff --git a/Windows_Notes.md b/Windows_Notes.md deleted file mode 100644 index f2f9445eb..000000000 --- a/Windows_Notes.md +++ /dev/null @@ -1,58 +0,0 @@ -# Getting your development environment set up properly -To get your environment up and running properly, you'll need a slightly different set of commands that are windows specific: -```bash -uv venv -.venv\Scripts\activate -uv pip install -e ".[dev]" -``` - -This will install the package in editable mode, and install the development dependencies. - - -# Fixing `AttributeError: module 'collections' has no attribute 'Callable'` -- open `.venv\Lib\site-packages\pyreadline\py3k_compat.py` -- change `return isinstance(x, collections.Callable)` to -``` -from collections.abc import Callable -return isinstance(x, Callable) -``` - -# Helpful notes -For developing FastMCP -## Install local development version of FastMCP into a local FastMCP project server -- ensure -- change directories to your FastMCP Server location so you can install it in your .venv -- run `.venv\Scripts\activate` to activate your virtual environment -- Then run a series of commands to uninstall the old version and install the new -```bash -# First uninstall -uv pip uninstall fastmcp - -# Clean any build artifacts in your fastmcp directory -cd C:\path\to\fastmcp -del /s /q *.egg-info - -# Then reinstall in your weather project -cd C:\path\to\new\fastmcp_server -uv pip install --no-cache-dir -e C:\Users\justj\PycharmProjects\fastmcp - -# Check that it installed properly and has the correct git hash -pip show fastmcp -``` - -## Running the FastMCP server with Inspector -MCP comes with a node.js application called Inspector that can be used to inspect the FastMCP server. To run the inspector, you'll need to install node.js and npm. Then you can run the following commands: -```bash -fastmcp dev server.py -``` -This will launch a web app on http://localhost:5173/ that you can use to inspect the FastMCP server. - -## If you start development before creating a fork - your get out of jail free card -- Add your fork as a new remote to your local repository `git remote add fork git@github.com:YOUR-USERNAME/REPOSITORY-NAME.git` - - This will add your repo, short named 'fork', as a remote to your local repository -- Verify that it was added correctly by running `git remote -v` -- Commit your changes -- Push your changes to your fork `git push fork ` -- Create your pull request on GitHub - - diff --git a/docs/servers/logging.mdx b/docs/servers/logging.mdx index 6e27aa72d..23283b083 100644 --- a/docs/servers/logging.mdx +++ b/docs/servers/logging.mdx @@ -1,5 +1,5 @@ --- -title: Server Logging +title: Client Logging sidebarTitle: Logging description: Send log messages back to MCP clients through the context. icon: receipt @@ -71,12 +71,25 @@ async def process_transaction(transaction_id: str, amount: float, ctx: Context): # ... processing logic ... ``` +## Server Logs + +Client Logging in the form of `ctx.log()` and its convenience methods (`debug`, `info`, `warning`, `error`) are meant for sending messages to the MCP clients. Messages sent to clients are also logged to the server's log at `DEBUG` level. Enable debug logging on the server or enable debug logging on the `fastmcp.server.context.to_client` logger to see these messages in the server's log. + +```python +import logging + +from fastmcp.utilities.logging import get_logger + +to_client_logger = get_logger(name="fastmcp.server.context.to_client") +to_client_logger.setLevel(level=logging.DEBUG) +``` + ## Logging Methods Send debug-level messages for detailed execution information - + The debug message to send to the client @@ -89,7 +102,7 @@ async def process_transaction(transaction_id: str, amount: float, ctx: Context): Send informational messages about normal execution - + The information message to send to the client @@ -102,7 +115,7 @@ async def process_transaction(transaction_id: str, amount: float, ctx: Context): Send warning messages for potential issues that didn't prevent execution - + The warning message to send to the client @@ -115,7 +128,7 @@ async def process_transaction(transaction_id: str, amount: float, ctx: Context): Send error messages for problems that occurred during execution - + The error message to send to the client @@ -128,16 +141,16 @@ async def process_transaction(transaction_id: str, amount: float, ctx: Context): Generic logging method with custom level and logger name - + The log level for the message - + The message to send to the client - + Optional custom logger name for categorizing messages diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx index c57ed571b..24ebcfa29 100644 --- a/docs/servers/prompts.mdx +++ b/docs/servers/prompts.mdx @@ -81,6 +81,10 @@ def data_analysis_prompt( Sets the explicit prompt name exposed via MCP. If not provided, uses the function name + + A human-readable title for the prompt + + Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose @@ -340,4 +344,4 @@ The duplicate behavior options are: - `"warn"` (default): Logs a warning, and the new prompt replaces the old one. - `"error"`: Raises a `ValueError`, preventing the duplicate registration. - `"replace"`: Silently replaces the existing prompt with the new one. -- `"ignore"`: Keeps the original prompt and ignores the new registration attempt. \ No newline at end of file +- `"ignore"`: Keeps the original prompt and ignores the new registration attempt. diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index 1fc903659..f4a5f1ae6 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -73,9 +73,15 @@ The `FastMCP` constructor accepts several arguments: How to handle duplicate prompt registrations + + + + Controls how tool input parameters are validated. When `False` (default), FastMCP uses Pydantic's flexible validation that coerces compatible inputs (e.g., `"10"` → `10` for int parameters). When `True`, uses the MCP SDK's JSON Schema validation to validate inputs against the exact schema before passing them to your function, rejecting any type mismatches. The default mode improves compatibility with LLM clients while maintaining type safety. See [Input Validation Modes](/servers/tools#input-validation-modes) for details + + - + Whether to include FastMCP metadata in component responses. When `True`, component tags and other FastMCP-specific metadata are included in the `_fastmcp` namespace within each component's `meta` field. When `False`, this metadata is omitted, resulting in cleaner integration with external systems. Can be overridden globally via `FASTMCP_INCLUDE_FASTMCP_META` environment variable @@ -336,6 +342,7 @@ import fastmcp print(fastmcp.settings.log_level) # Default: "INFO" print(fastmcp.settings.mask_error_details) # Default: False print(fastmcp.settings.resource_prefix_format) # Default: "path" +print(fastmcp.settings.strict_input_validation) # Default: False print(fastmcp.settings.include_fastmcp_meta) # Default: True ``` @@ -343,6 +350,7 @@ Common global settings include: - **`log_level`**: Logging level ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"), set with `FASTMCP_LOG_LEVEL` - **`mask_error_details`**: Whether to hide detailed error information from clients, set with `FASTMCP_MASK_ERROR_DETAILS` - **`resource_prefix_format`**: How to format resource prefixes ("path" or "protocol"), set with `FASTMCP_RESOURCE_PREFIX_FORMAT` +- **`strict_input_validation`**: Controls tool input validation mode (default: False for flexible coercion), set with `FASTMCP_STRICT_INPUT_VALIDATION`. See [Input Validation Modes](/servers/tools#input-validation-modes) - **`include_fastmcp_meta`**: Whether to include FastMCP metadata in component responses (default: True), set with `FASTMCP_INCLUDE_FASTMCP_META` - **`env_file`**: Path to the environment file to load settings from (default: ".env"), set with `FASTMCP_ENV_FILE`. Useful when your project uses a `.env` file with syntax incompatible with python-dotenv @@ -376,6 +384,7 @@ Global FastMCP settings can be configured via environment variables (prefixed wi export FASTMCP_LOG_LEVEL=DEBUG export FASTMCP_MASK_ERROR_DETAILS=True export FASTMCP_RESOURCE_PREFIX_FORMAT=protocol +export FASTMCP_STRICT_INPUT_VALIDATION=False export FASTMCP_INCLUDE_FASTMCP_META=False ``` diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 8da84c202..c9e73cfb8 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -9,8 +9,6 @@ import { VersionBadge } from '/snippets/version-badge.mdx' Tools are the core building blocks that allow your LLM to interact with external systems, execute code, and access data that isn't in its training data. In FastMCP, tools are Python functions exposed to LLMs through the MCP protocol. -## What Are Tools? - Tools in FastMCP transform regular Python functions into capabilities that LLMs can invoke during conversations. When an LLM decides to use a tool: 1. It sends a request with parameters based on the tool's schema. @@ -20,9 +18,8 @@ Tools in FastMCP transform regular Python functions into capabilities that LLMs This allows LLMs to perform tasks like querying databases, calling APIs, making calculations, or accessing files—extending their capabilities beyond what's in their training data. -## Tools -### The `@tool` Decorator +## The `@tool` Decorator Creating a tool is as simple as decorating a Python function with `@mcp.tool`: @@ -49,7 +46,7 @@ The way you define your Python function dictates how the tool appears and behave Functions with `*args` or `**kwargs` are not supported as tools. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists. -#### Decorator Arguments +### Decorator Arguments While FastMCP infers the name and description from your function, you can override these and add additional metadata using arguments to the `@mcp.tool` decorator: @@ -117,7 +114,7 @@ def search_products_implementation(query: str, category: str | None = None) -> l -### Async and Synchronous Tools +### Async Support FastMCP is an async-first framework that seamlessly supports both asynchronous (`async def`) and synchronous (`def`) functions as tools. Async tools are preferred for I/O-bound operations to keep your server responsive. @@ -170,15 +167,13 @@ def my_tool() -> None: ``` +## Arguments + +By default, FastMCP converts Python functions into MCP tools by inspecting the function's signature and type annotations. This allows you to use standard Python type annotations for your tools. In general, the framework strives to "just work": idiomatic Python behaviors like parameter defaults and type annotations are automatically translated into MCP schemas. However, there are a number of ways to customize the behavior of your tools. ### Type Annotations -Type annotations for parameters are essential for proper tool functionality. They: -1. Inform the LLM about the expected data types for each parameter -2. Enable FastMCP to validate input data from clients -3. Generate accurate JSON schemas for the MCP protocol - -Use standard Python type annotations for parameters: +MCP tools have typed arguments, and FastMCP uses type annotations to determine those types. Therefore, you should use standard Python type annotations for tool arguments: ```python @mcp.tool @@ -195,18 +190,83 @@ FastMCP supports a wide range of type annotations, including all Pydantic types: | Type Annotation | Example | Description | | :---------------------- | :---------------------------- | :---------------------------------- | -| Basic types | `int`, `float`, `str`, `bool` | Simple scalar values - see [Built-in Types](#built-in-types) | -| Binary data | `bytes` | Binary content - see [Binary Data](#binary-data) | -| Date and Time | `datetime`, `date`, `timedelta` | Date and time objects - see [Date and Time Types](#date-and-time-types) | -| Collection types | `list[str]`, `dict[str, int]`, `set[int]` | Collections of items - see [Collection Types](#collection-types) | -| Optional types | `float \| None`, `Optional[float]`| Parameters that may be null/omitted - see [Union and Optional Types](#union-and-optional-types) | -| Union types | `str \| int`, `Union[str, int]`| Parameters accepting multiple types - see [Union and Optional Types](#union-and-optional-types) | -| Constrained types | `Literal["A", "B"]`, `Enum` | Parameters with specific allowed values - see [Constrained Types](#constrained-types) | -| Paths | `Path` | File system paths - see [Paths](#paths) | -| UUIDs | `UUID` | Universally unique identifiers - see [UUIDs](#uuids) | -| Pydantic models | `UserData` | Complex structured data - see [Pydantic Models](#pydantic-models) | +| Basic types | `int`, `float`, `str`, `bool` | Simple scalar values | +| Binary data | `bytes` | Binary content (raw strings, not auto-decoded base64) | +| Date and Time | `datetime`, `date`, `timedelta` | Date and time objects (ISO format strings) | +| Collection types | `list[str]`, `dict[str, int]`, `set[int]` | Collections of items | +| Optional types | `float \| None`, `Optional[float]`| Parameters that may be null/omitted | +| Union types | `str \| int`, `Union[str, int]`| Parameters accepting multiple types | +| Constrained types | `Literal["A", "B"]`, `Enum` | Parameters with specific allowed values | +| Paths | `Path` | File system paths (auto-converted from strings) | +| UUIDs | `UUID` | Universally unique identifiers (auto-converted from strings) | +| Pydantic models | `UserData` | Complex structured data with validation | + +FastMCP supports all types that Pydantic supports as fields, including all Pydantic custom types. A few FastMCP-specific behaviors to note: + +**Binary Data**: `bytes` parameters accept raw strings without automatic base64 decoding. For base64 data, use `str` and decode manually with `base64.b64decode()`. + +**Enums**: Clients send enum values (`"red"`), not names (`"RED"`). Your function receives the Enum member (`Color.RED`). + +**Paths and UUIDs**: String inputs are automatically converted to `Path` and `UUID` objects. + +**Pydantic Models**: Must be provided as JSON objects (dicts), not stringified JSON. Even with flexible validation, `{"user": {"name": "Alice"}}` works, but `{"user": '{"name": "Alice"}'}` does not. + +### Optional Arguments + +FastMCP follows Python's standard function parameter conventions. Parameters without default values are required, while those with default values are optional. + +```python +@mcp.tool +def search_products( + query: str, # Required - no default value + max_results: int = 10, # Optional - has default value + sort_by: str = "relevance", # Optional - has default value + category: str | None = None # Optional - can be None +) -> list[dict]: + """Search the product catalog.""" + # Implementation... +``` + +In this example, the LLM must provide a `query` parameter, while `max_results`, `sort_by`, and `category` will use their default values if not explicitly provided. + +### Validation Modes + + + +By default, FastMCP uses Pydantic's flexible validation that coerces compatible inputs to match your type annotations. This improves compatibility with LLM clients that may send string representations of values (like `"10"` for an integer parameter). + +If you need stricter validation that rejects any type mismatches, you can enable strict input validation. Strict mode uses the MCP SDK's built-in JSON Schema validation to validate inputs against the exact schema before passing them to your function: + +```python +# Enable strict validation for this server +mcp = FastMCP("StrictServer", strict_input_validation=True) + +@mcp.tool +def add_numbers(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + +# With strict_input_validation=True, sending {"a": "10", "b": "20"} will fail +# With strict_input_validation=False (default), it will be coerced to integers +``` + +**Validation Behavior Comparison:** + +| Input Type | strict_input_validation=False (default) | strict_input_validation=True | +| :--------- | :-------------------------------------- | :--------------------------- | +| String integers (`"10"` for `int`) | ✅ Coerced to integer | ❌ Validation error | +| String floats (`"3.14"` for `float`) | ✅ Coerced to float | ❌ Validation error | +| String booleans (`"true"` for `bool`) | ✅ Coerced to boolean | ❌ Validation error | +| Lists with string elements (`["1", "2"]` for `list[int]`) | ✅ Elements coerced | ❌ Validation error | +| Pydantic model fields with type mismatches | ✅ Fields coerced | ❌ Validation error | +| Invalid values (`"abc"` for `int`) | ❌ Validation error | ❌ Validation error | + + +**Note on Pydantic Models:** Even with `strict_input_validation=False`, Pydantic model parameters must be provided as JSON objects (dicts), not as stringified JSON. For example, `{"user": {"name": "Alice"}}` works, but `{"user": '{"name": "Alice"}'}` does not. + + +The default flexible validation mode is recommended for most use cases as it handles common LLM client behaviors gracefully while still providing strong type safety through Pydantic's validation. -For additional type annotations not listed here, see the [Parameter Types](#parameter-types) section below for more detailed information and examples. ### Parameter Metadata You can provide additional metadata about parameters in several ways: @@ -281,24 +341,6 @@ Field provides several validation and documentation features: -### Optional Arguments - -FastMCP follows Python's standard function parameter conventions. Parameters without default values are required, while those with default values are optional. - -```python -@mcp.tool -def search_products( - query: str, # Required - no default value - max_results: int = 10, # Optional - has default value - sort_by: str = "relevance", # Optional - has default value - category: str | None = None # Optional - can be None -) -> list[dict]: - """Search the product catalog.""" - # Implementation... -``` - -In this example, the LLM must provide a `query` parameter, while `max_results`, `sort_by`, and `category` will use their default values if not explicitly provided. - ### Excluding Arguments @@ -322,34 +364,8 @@ With this configuration, `user_id` will not appear in the tool's parameter schem For more complex tool transformations, see [Transforming Tools](/patterns/tool-transformation). -### Disabling Tools - - -You can control the visibility and availability of tools by enabling or disabling them. This is useful for feature flagging, maintenance, or dynamically changing the toolset available to a client. Disabled tools will not appear in the list of available tools returned by `list_tools`, and attempting to call a disabled tool will result in an "Unknown tool" error, just as if the tool did not exist. - -By default, all tools are enabled. You can disable a tool upon creation using the `enabled` parameter in the decorator: - -```python -@mcp.tool(enabled=False) -def maintenance_tool(): - """This tool is currently under maintenance.""" - return "This tool is disabled." -``` - -You can also toggle a tool's state programmatically after it has been created: - -```python -@mcp.tool -def dynamic_tool(): - return "I am a dynamic tool." - -# Disable and re-enable the tool -dynamic_tool.disable() -dynamic_tool.enable() -``` - -### Return Values +## Return Values FastMCP tools can return data in two complementary formats: **traditional content blocks** (like text and images) and **structured outputs** (machine-readable JSON). When you add return type annotations, FastMCP automatically generates **output schemas** to validate the structured data and enables clients to deserialize results back to Python objects. @@ -362,7 +378,7 @@ Understanding how these three concepts work together: The following sections explain each concept in detail. -#### Content Blocks +### Content Blocks FastMCP automatically converts tool return values into appropriate MCP content blocks: @@ -374,7 +390,43 @@ FastMCP automatically converts tool return values into appropriate MCP content b - **A list of any of the above**: Converts each item appropriately - **`None`**: Results in an empty response -#### Structured Output +#### Media Helper Classes + +For returning images, audio, and files, FastMCP provides helper classes that handle MIME type detection and base64 encoding automatically, returning them in MCP-native formats that meet the protocol's requirements: + +```python +from fastmcp.utilities.types import Image, Audio, File + +@mcp.tool +def get_chart() -> Image: + """Generate a chart image.""" + # From file path - MIME type detected from extension + return Image(path="chart.png") + + # Or from raw bytes with explicit format + # return Image(data=image_bytes, format="png") + +@mcp.tool +def get_recording() -> Audio: + """Get an audio recording.""" + return Audio(path="recording.wav") + # Or: Audio(data=audio_bytes, format="wav") + +@mcp.tool +def get_document() -> File: + """Retrieve a PDF document.""" + return File(path="report.pdf") + # Or: File(data=pdf_bytes, format="pdf", name="report") +``` + +Each helper class accepts either `path=` or `data=` (mutually exclusive): +- **`path`**: File path (string or Path object) - MIME type detected from extension +- **`data`**: Raw bytes - requires `format=` parameter for MIME type +- **`format`**: Optional format override (e.g., "png", "wav", "pdf") +- **`name`**: Optional name for `File` when using `data=` +- **`annotations`**: Optional MCP annotations for the content + +### Structured Output @@ -389,7 +441,7 @@ The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/speci This automatic behavior enables clients to receive machine-readable data alongside human-readable content without requiring explicit output schemas for object-like returns. -##### Object-like Results (Automatic Structured Content) +#### Object-like Results (Automatic Structured Content) ```python Dict Return (No Schema Needed) @@ -412,7 +464,7 @@ def get_user_data(user_id: str) -> dict: ``` -##### Non-object Results (Schema Required) +#### Non-object Results (Schema Required) ```python Integer Return (No Schema) @@ -444,7 +496,7 @@ def calculate_sum(a: int, b: int) -> int: ``` -##### Complex Type Example +#### Complex Type Example ```python Tool Definition @@ -487,7 +539,7 @@ def get_user_profile(user_id: str) -> Person: ``` -#### Output Schemas +### Output Schemas @@ -495,7 +547,7 @@ The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/speci When you add return type annotations to your functions, FastMCP automatically generates JSON schemas that describe the expected output format. These schemas help MCP clients understand and validate the structured data they receive. -##### Primitive Type Wrapping +#### Primitive Type Wrapping For primitive return types (like `int`, `str`, `bool`), FastMCP automatically wraps the result under a `"result"` key to create valid structured output: @@ -524,7 +576,7 @@ def calculate_sum(a: int, b: int) -> int: ``` -##### Manual Schema Control +#### Manual Schema Control You can override the automatically generated schema by providing a custom `output_schema`: @@ -550,7 +602,7 @@ Schema generation works for most common types including basic types, collections - However, you can provide structured output without an output schema (using `ToolResult`) -#### Full Control with ToolResult +### Full Control with ToolResult For complete control over both traditional content and structured output, return a `ToolResult` object: @@ -575,7 +627,7 @@ When returning `ToolResult`: If your return type annotation cannot be converted to a JSON schema (e.g., complex custom classes without Pydantic support), the output schema will be omitted but the tool will still function normally with traditional content. -### Error Handling +## Error Handling @@ -613,7 +665,33 @@ def divide(a: float, b: float) -> float: When `mask_error_details=True`, only error messages from `ToolError` will include details, other exceptions will be converted to a generic message. -### Annotations +## Disabling Tools + + + +You can control the visibility and availability of tools by enabling or disabling them. This is useful for feature flagging, maintenance, or dynamically changing the toolset available to a client. Disabled tools will not appear in the list of available tools returned by `list_tools`, and attempting to call a disabled tool will result in an "Unknown tool" error, just as if the tool did not exist. + +By default, all tools are enabled. You can disable a tool upon creation using the `enabled` parameter in the decorator: + +```python +@mcp.tool(enabled=False) +def maintenance_tool(): + """This tool is currently under maintenance.""" + return "This tool is disabled." +``` + +You can also toggle a tool's state programmatically after it has been created: + +```python +@mcp.tool +def dynamic_tool(): + return "I am a dynamic tool." + +# Disable and re-enable the tool +dynamic_tool.disable() +dynamic_tool.enable() +``` +## MCP Annotations @@ -652,7 +730,7 @@ FastMCP supports these standard annotations: Remember that annotations help make better user experiences but should be treated as advisory hints. They help client applications present appropriate UI elements and safety controls, but won't enforce security boundaries on their own. Always focus on making your annotations accurately represent what your tool actually does. -### Notifications +## Notifications @@ -674,7 +752,7 @@ Notifications are only sent when these operations occur within an active MCP req Clients can handle these notifications using a [message handler](/clients/messages) to automatically refresh their tool lists or update their interfaces. -## MCP Context +## Accessing the MCP Context Tools can access MCP features like logging, reading resources, or reporting progress through the `Context` object. To use it, add a parameter to your tool function with the type hint `Context`. @@ -715,333 +793,6 @@ The Context object provides access to: For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context). -## Parameter Types - -FastMCP supports a wide variety of parameter types to give you flexibility when designing your tools. - -FastMCP generally supports all types that Pydantic supports as fields, including all Pydantic custom types. This means you can use any type that can be validated and parsed by Pydantic in your tool parameters. - -FastMCP supports **type coercion** when possible. This means that if a client sends data that doesn't match the expected type, FastMCP will attempt to convert it to the appropriate type. For example, if a client sends a string for a parameter annotated as `int`, FastMCP will attempt to convert it to an integer. If the conversion is not possible, FastMCP will return a validation error. - -### Built-in Types - -The most common parameter types are Python's built-in scalar types: - -```python -@mcp.tool -def process_values( - name: str, # Text data - count: int, # Integer numbers - amount: float, # Floating point numbers - enabled: bool # Boolean values (True/False) -): - """Process various value types.""" - # Implementation... -``` - -These types provide clear expectations to the LLM about what values are acceptable and allow FastMCP to validate inputs properly. Even if a client provides a string like "42", it will be coerced to an integer for parameters annotated as `int`. - -### Date and Time Types - -FastMCP supports various date and time types from the `datetime` module: - -```python -from datetime import datetime, date, timedelta - -@mcp.tool -def process_date_time( - event_date: date, # ISO format date string or date object - event_time: datetime, # ISO format datetime string or datetime object - duration: timedelta = timedelta(hours=1) # Integer seconds or timedelta -) -> str: - """Process date and time information.""" - # Types are automatically converted from strings - assert isinstance(event_date, date) - assert isinstance(event_time, datetime) - assert isinstance(duration, timedelta) - - return f"Event on {event_date} at {event_time} for {duration}" -``` - -- `datetime` - Accepts ISO format strings (e.g., "2023-04-15T14:30:00") -- `date` - Accepts ISO format date strings (e.g., "2023-04-15") -- `timedelta` - Accepts integer seconds or timedelta objects - -### Collection Types - -FastMCP supports all standard Python collection types: - -```python -@mcp.tool -def analyze_data( - values: list[float], # List of numbers - properties: dict[str, str], # Dictionary with string keys and values - unique_ids: set[int], # Set of unique integers - coordinates: tuple[float, float], # Tuple with fixed structure - mixed_data: dict[str, list[int]] # Nested collections -): - """Analyze collections of data.""" - # Implementation... -``` - -All collection types can be used as parameter annotations: -- `list[T]` - Ordered sequence of items -- `dict[K, V]` - Key-value mapping -- `set[T]` - Unordered collection of unique items -- `tuple[T1, T2, ...]` - Fixed-length sequence with potentially different types - -Collection types can be nested and combined to represent complex data structures. JSON strings that match the expected structure will be automatically parsed and converted to the appropriate Python collection type. - -### Union and Optional Types - -For parameters that can accept multiple types or may be omitted: - -```python -@mcp.tool -def flexible_search( - query: str | int, # Can be either string or integer - filters: dict[str, str] | None = None, # Optional dictionary - sort_field: str | None = None # Optional string -): - """Search with flexible parameter types.""" - # Implementation... -``` - -Modern Python syntax (`str | int`) is preferred over older `Union[str, int]` forms. Similarly, `str | None` is preferred over `Optional[str]`. - -### Constrained Types - -When a parameter must be one of a predefined set of values, you can use either Literal types or Enums: - -#### Literals - -Literals constrain parameters to a specific set of values: - -```python -from typing import Literal - -@mcp.tool -def sort_data( - data: list[float], - order: Literal["ascending", "descending"] = "ascending", - algorithm: Literal["quicksort", "mergesort", "heapsort"] = "quicksort" -): - """Sort data using specific options.""" - # Implementation... -``` - -Literal types: -- Specify exact allowable values directly in the type annotation -- Help LLMs understand exactly which values are acceptable -- Provide input validation (errors for invalid values) -- Create clear schemas for clients - -#### Enums - -For more structured sets of constrained values, use Python's Enum class: - -```python -from enum import Enum - -class Color(Enum): - RED = "red" - GREEN = "green" - BLUE = "blue" - -@mcp.tool -def process_image( - image_path: str, - color_filter: Color = Color.RED -): - """Process an image with a color filter.""" - # Implementation... - # color_filter will be a Color enum member -``` - -When using Enum types: -- Clients should provide the enum's value (e.g., "red"), not the enum member name (e.g., "RED") -- FastMCP automatically coerces the string value into the appropriate Enum object -- Your function receives the actual Enum member (e.g., `Color.RED`) -- Validation errors are raised for values not in the enum - -### Binary Data - -There are two approaches to handling binary data in tool parameters: - -#### Bytes - -```python -@mcp.tool -def process_binary(data: bytes): - """Process binary data directly. - - The client can send a binary string, which will be - converted directly to bytes. - """ - # Implementation using binary data - data_length = len(data) - # ... -``` - -When you annotate a parameter as `bytes`, FastMCP will: -- Convert raw strings directly to bytes -- Validate that the input can be properly represented as bytes - -FastMCP does not automatically decode base64-encoded strings for bytes parameters. If you need to accept base64-encoded data, you should handle the decoding manually as shown below. - -#### Base64-encoded strings - -```python -from typing import Annotated -from pydantic import Field - -@mcp.tool -def process_image_data( - image_data: Annotated[str, Field(description="Base64-encoded image data")] -): - """Process an image from base64-encoded string. - - The client is expected to provide base64-encoded data as a string. - You'll need to decode it manually. - """ - # Manual base64 decoding - import base64 - binary_data = base64.b64decode(image_data) - # Process binary_data... -``` - -This approach is recommended when you expect to receive base64-encoded binary data from clients. - -### Paths - -The `Path` type from the `pathlib` module can be used for file system paths: - -```python -from pathlib import Path - -@mcp.tool -def process_file(path: Path) -> str: - """Process a file at the given path.""" - assert isinstance(path, Path) # Path is properly converted - return f"Processing file at {path}" -``` - -When a client sends a string path, FastMCP automatically converts it to a `Path` object. - -### UUIDs - -The `UUID` type from the `uuid` module can be used for unique identifiers: - -```python -import uuid - -@mcp.tool -def process_item( - item_id: uuid.UUID # String UUID or UUID object -) -> str: - """Process an item with the given UUID.""" - assert isinstance(item_id, uuid.UUID) # Properly converted to UUID - return f"Processing item {item_id}" -``` - -When a client sends a string UUID (e.g., "123e4567-e89b-12d3-a456-426614174000"), FastMCP automatically converts it to a `UUID` object. - -### Pydantic Models - -For complex, structured data with nested fields and validation, use Pydantic models: - -```python -from pydantic import BaseModel, Field -from typing import Optional - -class User(BaseModel): - username: str - email: str = Field(description="User's email address") - age: int | None = None - is_active: bool = True - -@mcp.tool -def create_user(user: User): - """Create a new user in the system.""" - # The input is automatically validated against the User model - # Even if provided as a JSON string or dict - # Implementation... -``` - -Using Pydantic models provides: -- Clear, self-documenting structure for complex inputs -- Built-in data validation -- Automatic generation of detailed JSON schemas for the LLM -- Automatic conversion from dict/JSON input - -Clients can provide data for Pydantic model parameters as either: -- A JSON object (string) -- A dictionary with the appropriate structure -- Nested parameters in the appropriate format - -### Pydantic Fields - -FastMCP supports robust parameter validation through Pydantic's `Field` class. This is especially useful to ensure that input values meet specific requirements beyond just their type. - -Note that fields can be used *outside* Pydantic models to provide metadata and validation constraints. The preferred approach is using `Annotated` with `Field`: - -```python -from typing import Annotated -from pydantic import Field - -@mcp.tool -def analyze_metrics( - # Numbers with range constraints - count: Annotated[int, Field(ge=0, le=100)], # 0 <= count <= 100 - ratio: Annotated[float, Field(gt=0, lt=1.0)], # 0 < ratio < 1.0 - - # String with pattern and length constraints - user_id: Annotated[str, Field( - pattern=r"^[A-Z]{2}\d{4}$", # Must match regex pattern - description="User ID in format XX0000" - )], - - # String with length constraints - comment: Annotated[str, Field(min_length=3, max_length=500)] = "", - - # Numeric constraints - factor: Annotated[int, Field(multiple_of=5)] = 10, # Must be multiple of 5 -): - """Analyze metrics with validated parameters.""" - # Implementation... -``` - -You can also use `Field` as a default value, though the `Annotated` approach is preferred: - -```python -@mcp.tool -def validate_data( - # Value constraints - age: int = Field(ge=0, lt=120), # 0 <= age < 120 - - # String constraints - email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$"), # Email pattern - - # Collection constraints - tags: list[str] = Field(min_length=1, max_length=10) # 1-10 tags -): - """Process data with field validations.""" - # Implementation... -``` - -Common validation options include: - -| Validation | Type | Description | -| :--------- | :--- | :---------- | -| `ge`, `gt` | Number | Greater than (or equal) constraint | -| `le`, `lt` | Number | Less than (or equal) constraint | -| `multiple_of` | Number | Value must be a multiple of this number | -| `min_length`, `max_length` | String, List, etc. | Length constraints | -| `pattern` | String | Regular expression pattern constraint | -| `description` | Any | Human-readable description (appears in schema) | - -When a client sends invalid data, FastMCP will return a validation error explaining why the parameter failed validation. - ## Server Behavior ### Duplicate Tools diff --git a/pyproject.toml b/pyproject.toml index 8ec41cf8b..bd6563745 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "pydantic[email]>=2.11.7", "pyperclip>=1.9.0", "openapi-core>=0.19.5", - "py-key-value-aio[disk,memory]>=0.2.1", + "py-key-value-aio[disk,memory]>=0.2.2", "websockets>=15.0.1", ] diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index d5f373db0..42f582b1c 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -46,9 +46,7 @@ def _get_npx_command(): # Try both npx.cmd and npx.exe on Windows for cmd in ["npx.cmd", "npx.exe", "npx"]: try: - subprocess.run( - [cmd, "--version"], check=True, capture_output=True, shell=True - ) + subprocess.run([cmd, "--version"], check=True, capture_output=True) return cmd except subprocess.CalledProcessError: continue @@ -277,12 +275,10 @@ async def dev( # 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" + # Run the MCP Inspector command process = subprocess.run( [npx_cmd, inspector_cmd] + uv_cmd, check=True, - shell=shell, env=env, ) sys.exit(process.returncode) diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index dd885e5ea..ee0edb2c6 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -56,7 +56,7 @@ def open_deeplink(deeplink: str) -> bool: subprocess.run(["open", deeplink], check=True, capture_output=True) elif sys.platform == "win32": # Windows subprocess.run( - ["start", deeplink], shell=True, check=True, capture_output=True + ["cmd", "/c", "start", deeplink], check=True, capture_output=True ) else: # Linux and others subprocess.run(["xdg-open", deeplink], check=True, capture_output=True) diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index 5c4f386db..f03578a11 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -172,7 +172,7 @@ async def run_command( # handle v1 servers if isinstance(server, FastMCP1x): - run_v1_server(server, host=host, port=port, transport=transport) + await run_v1_server_async(server, host=host, port=port, transport=transport) return kwargs = {} @@ -197,24 +197,29 @@ async def run_command( sys.exit(1) -def run_v1_server( +async def run_v1_server_async( server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None, ) -> None: - from functools import partial + """Run a FastMCP 1.x server using async methods. + Args: + server: FastMCP 1.x server instance + host: Host to bind to + port: Port to bind to + transport: Transport protocol to use + """ if host: server.settings.host = host if port: server.settings.port = port + match transport: case "stdio": - runner = partial(server.run) + await server.run_stdio_async() case "http" | "streamable-http" | None: - runner = partial(server.run, transport="streamable-http") + await server.run_streamable_http_async() case "sse": - runner = partial(server.run, transport="sse") - - runner() + await server.run_sse_async() diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 0e088953d..224aeb45c 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -191,6 +191,14 @@ class OAuth(OAuthClientProvider): # Create server-specific token storage token_storage = token_storage or MemoryStore() + if isinstance(token_storage, MemoryStore): + from warnings import warn + + warn( + message="Using in-memory token storage is not recommended for production use -- " + + "tokens will be lost on server restart." + ) + self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter( async_key_value=token_storage, server_url=server_base_url ) diff --git a/src/fastmcp/client/logging.py b/src/fastmcp/client/logging.py index 62a83db27..6451591c5 100644 --- a/src/fastmcp/client/logging.py +++ b/src/fastmcp/client/logging.py @@ -1,4 +1,5 @@ from collections.abc import Awaitable, Callable +from logging import Logger from typing import TypeAlias from mcp.client.session import LoggingFnT @@ -6,7 +7,8 @@ from mcp.types import LoggingMessageNotificationParams from fastmcp.utilities.logging import get_logger -logger = get_logger(__name__) +logger: Logger = get_logger(name=__name__) +from_server_logger: Logger = get_logger(name="fastmcp.client.from_server") LogMessage: TypeAlias = LoggingMessageNotificationParams LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]] @@ -19,25 +21,27 @@ async def default_log_handler(message: LogMessage) -> None: # Map MCP log levels to Python logging levels level_map = { - "debug": logger.debug, - "info": logger.info, - "notice": logger.info, # Python doesn't have 'notice', map to info - "warning": logger.warning, - "error": logger.error, - "critical": logger.critical, - "alert": logger.critical, # Map alert to critical - "emergency": logger.critical, # Map emergency to critical + "debug": from_server_logger.debug, + "info": from_server_logger.info, + "notice": from_server_logger.info, # Python doesn't have 'notice', map to info + "warning": from_server_logger.warning, + "error": from_server_logger.error, + "critical": from_server_logger.critical, + "alert": from_server_logger.critical, # Map alert to critical + "emergency": from_server_logger.critical, # Map emergency to critical } # Get the appropriate logging function based on the message level log_fn = level_map.get(message.level.lower(), logger.info) # Include logger name if available + msg_prefix: str = f"Received {message.level.upper()} from server" + if message.logger: - msg = f"[{message.logger}] {msg}" + msg_prefix += f" ({message.logger})" # Log with appropriate level and extra data - log_fn(f"Server log: {msg}", extra=extra) + log_fn(msg=f"{msg_prefix}: {msg}", extra=extra) def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT: diff --git a/src/fastmcp/client/oauth_callback.py b/src/fastmcp/client/oauth_callback.py index cf1166a80..ced483ea2 100644 --- a/src/fastmcp/client/oauth_callback.py +++ b/src/fastmcp/client/oauth_callback.py @@ -46,9 +46,7 @@ def create_callback_html( # Add detail info box for both success and error cases detail_info = "" if is_success and server_url: - detail_info = create_info_box( - f"Connected to: {server_url}", centered=True - ) + detail_info = create_info_box(f"Connected to: {server_url}", centered=True) elif not is_success: detail_info = create_info_box(message, is_error=True, centered=True) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 1016d35e0..2cadd0631 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -8,7 +8,7 @@ import sys import warnings from collections.abc import AsyncIterator from pathlib import Path -from typing import Any, Literal, TypeVar, cast, overload +from typing import Any, Literal, TextIO, TypeVar, cast, overload import anyio import httpx @@ -313,6 +313,7 @@ class StdioTransport(ClientTransport): env: dict[str, str] | None = None, cwd: str | None = None, keep_alive: bool | None = None, + log_file: Path | TextIO | None = None, ): """ Initialize a Stdio transport. @@ -326,6 +327,11 @@ class StdioTransport(ClientTransport): Defaults to True. When True, the subprocess remains active after the connection context exits, allowing reuse in subsequent connections. + log_file: Optional path or file-like object where subprocess stderr will + be written. Can be a Path or TextIO object. Defaults to sys.stderr + if not provided. When a Path is provided, the file will be created + if it doesn't exist, or appended to if it does. When set, server + errors will be written to this file instead of appearing in the console. """ self.command = command self.args = args @@ -334,6 +340,7 @@ class StdioTransport(ClientTransport): if keep_alive is None: keep_alive = True self.keep_alive = keep_alive + self.log_file = log_file self._session: ClientSession | None = None self._connect_task: asyncio.Task | None = None @@ -368,6 +375,7 @@ class StdioTransport(ClientTransport): args=self.args, env=self.env, cwd=self.cwd, + log_file=self.log_file, session_kwargs=session_kwargs, ready_event=self._ready_event, stop_event=self._stop_event, @@ -421,6 +429,7 @@ async def _stdio_transport_connect_task( args: list[str], env: dict[str, str] | None, cwd: str | None, + log_file: Path | TextIO | None, session_kwargs: SessionKwargs, ready_event: anyio.Event, stop_event: anyio.Event, @@ -438,7 +447,19 @@ async def _stdio_transport_connect_task( env=env, cwd=cwd, ) - transport = await stack.enter_async_context(stdio_client(server_params)) + # Handle log_file: Path needs to be opened, TextIO used as-is + if log_file is None: + log_file_handle = sys.stderr + elif isinstance(log_file, Path): + log_file_handle = open(log_file, "a") + stack.callback(log_file_handle.close) + else: + # Must be TextIO - use it directly + log_file_handle = log_file + + transport = await stack.enter_async_context( + stdio_client(server_params, errlog=log_file_handle) + ) read_stream, write_stream = transport session_future.set_result( await stack.enter_async_context( @@ -471,6 +492,7 @@ class PythonStdioTransport(StdioTransport): cwd: str | None = None, python_cmd: str = sys.executable, keep_alive: bool | None = None, + log_file: Path | TextIO | None = None, ): """ Initialize a Python transport. @@ -485,6 +507,11 @@ class PythonStdioTransport(StdioTransport): Defaults to True. When True, the subprocess remains active after the connection context exits, allowing reuse in subsequent connections. + log_file: Optional path or file-like object where subprocess stderr will + be written. Can be a Path or TextIO object. Defaults to sys.stderr + if not provided. When a Path is provided, the file will be created + if it doesn't exist, or appended to if it does. When set, server + errors will be written to this file instead of appearing in the console. """ script_path = Path(script_path).resolve() if not script_path.is_file(): @@ -502,6 +529,7 @@ class PythonStdioTransport(StdioTransport): env=env, cwd=cwd, keep_alive=keep_alive, + log_file=log_file, ) self.script_path = script_path @@ -516,6 +544,7 @@ class FastMCPStdioTransport(StdioTransport): env: dict[str, str] | None = None, cwd: str | None = None, keep_alive: bool | None = None, + log_file: Path | TextIO | None = None, ): script_path = Path(script_path).resolve() if not script_path.is_file(): @@ -529,6 +558,7 @@ class FastMCPStdioTransport(StdioTransport): env=env, cwd=cwd, keep_alive=keep_alive, + log_file=log_file, ) self.script_path = script_path @@ -544,6 +574,7 @@ class NodeStdioTransport(StdioTransport): cwd: str | None = None, node_cmd: str = "node", keep_alive: bool | None = None, + log_file: Path | TextIO | None = None, ): """ Initialize a Node transport. @@ -558,6 +589,11 @@ class NodeStdioTransport(StdioTransport): Defaults to True. When True, the subprocess remains active after the connection context exits, allowing reuse in subsequent connections. + log_file: Optional path or file-like object where subprocess stderr will + be written. Can be a Path or TextIO object. Defaults to sys.stderr + if not provided. When a Path is provided, the file will be created + if it doesn't exist, or appended to if it does. When set, server + errors will be written to this file instead of appearing in the console. """ script_path = Path(script_path).resolve() if not script_path.is_file(): @@ -570,7 +606,12 @@ class NodeStdioTransport(StdioTransport): full_args.extend(args) super().__init__( - command=node_cmd, args=full_args, env=env, cwd=cwd, keep_alive=keep_alive + command=node_cmd, + args=full_args, + env=env, + cwd=cwd, + keep_alive=keep_alive, + log_file=log_file, ) self.script_path = script_path @@ -811,29 +852,42 @@ class FastMCPTransport(ClientTransport): # Create a cancel scope for the server task async with anyio.create_task_group() as tg: - tg.start_soon( - lambda: self.server._mcp_server.run( - server_read, - server_write, - self.server._mcp_server.create_initialization_options(), - raise_exceptions=self.raise_exceptions, + async with _enter_server_lifespan(server=self.server): + tg.start_soon( + lambda: self.server._mcp_server.run( + server_read, + server_write, + self.server._mcp_server.create_initialization_options(), + raise_exceptions=self.raise_exceptions, + ) ) - ) - try: - async with ClientSession( - read_stream=client_read, - write_stream=client_write, - **session_kwargs, - ) as client_session: - yield client_session - finally: - tg.cancel_scope.cancel() + try: + async with ClientSession( + read_stream=client_read, + write_stream=client_write, + **session_kwargs, + ) as client_session: + yield client_session + finally: + tg.cancel_scope.cancel() def __repr__(self) -> str: return f"" +@contextlib.asynccontextmanager +async def _enter_server_lifespan( + server: FastMCP | FastMCP1Server, +) -> AsyncIterator[None]: + """Enters the server's lifespan context for FastMCP servers and does nothing for FastMCP 1 servers.""" + if isinstance(server, FastMCP): + async with server._lifespan_manager(): + yield + else: + yield + + class MCPConfigTransport(ClientTransport): """Transport for connecting to one or more MCP servers defined in an MCPConfig. diff --git a/src/fastmcp/contrib/mcp_mixin/README.md b/src/fastmcp/contrib/mcp_mixin/README.md index 0742d7b6a..39c3a2352 100644 --- a/src/fastmcp/contrib/mcp_mixin/README.md +++ b/src/fastmcp/contrib/mcp_mixin/README.md @@ -11,12 +11,15 @@ Tools: * [enable/disable](https://gofastmcp.com/servers/tools#disabling-tools) * [annotations](https://gofastmcp.com/servers/tools#annotations-2) * [excluded arguments](https://gofastmcp.com/servers/tools#excluding-arguments) +* [meta](https://gofastmcp.com/servers/tools#param-meta) Prompts: * [enable/disable](https://gofastmcp.com/servers/prompts#disabling-prompts) +* [meta](https://gofastmcp.com/servers/prompts#param-meta) Resources: * [enable/disable](https://gofastmcp.com/servers/resources#disabling-resources) +* [meta](https://gofastmcp.com/servers/resources#param-meta) ## Usage @@ -78,7 +81,16 @@ class MyComponent(MCPMixin): if delete_all: return "99 records deleted. I bet you're not a tool :)" return "Tool executed, but you might be a tool!" - + + # example tool w/ meta + @mcp_tool( + name="data_tool", + description="Fetches user data from database", + meta={"version": "2.0", "category": "database", "author": "dev-team"} + ) + def data_tool_method(self, user_id: int): + return f"Fetching data for user {user_id}" + @mcp_resource(uri="component://data") def resource_method(self): return {"data": "some data"} @@ -88,6 +100,15 @@ class MyComponent(MCPMixin): def resource_method(self): return {"data": "some data"} + # example resource w/meta and title + @mcp_resource( + uri="component://config", + title="Data resource Title, + meta={"internal": True, "cache_ttl": 3600, "priority": "high"} + ) + def config_resource_method(self): + return {"config": "data"} + # prompt @mcp_prompt(name="A prompt") def prompt_method(self, name): @@ -98,6 +119,16 @@ class MyComponent(MCPMixin): def prompt_method(self, name): return f"What's up {name}?" + # example prompt w/title and meta + @mcp_prompt( + name="analysis_prompt", + title="Data Analysis Prompt", + description="Analyzes data patterns", + meta={"complexity": "high", "domain": "analytics", "requires_context": True} + ) + def analysis_prompt_method(self, dataset: str): + return f"Analyze the patterns in {dataset}" + mcp_server = FastMCP() component = MyComponent() diff --git a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py index 8e11e6342..5688fa125 100644 --- a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py +++ b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py @@ -3,7 +3,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any -from mcp.types import ToolAnnotations +from mcp.types import Annotations, ToolAnnotations from fastmcp.prompts.prompt import Prompt from fastmcp.resources.resource import Resource @@ -29,6 +29,7 @@ def mcp_tool( annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, serializer: Callable[[Any], str] | None = None, + meta: dict[str, Any] | None = None, enabled: bool | None = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to mark a method as an MCP tool for later registration.""" @@ -41,6 +42,7 @@ def mcp_tool( "annotations": annotations, "exclude_args": exclude_args, "serializer": serializer, + "meta": meta, "enabled": enabled, } call_args = {k: v for k, v in call_args.items() if v is not None} @@ -54,9 +56,12 @@ def mcp_resource( uri: str, *, name: str | None = None, + title: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, + annotations: Annotations | None = None, + meta: dict[str, Any] | None = None, enabled: bool | None = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to mark a method as an MCP resource for later registration.""" @@ -65,9 +70,12 @@ def mcp_resource( call_args = { "uri": uri, "name": name or get_fn_name(func), + "title": title, "description": description, "mime_type": mime_type, "tags": tags, + "annotations": annotations, + "meta": meta, "enabled": enabled, } call_args = {k: v for k, v in call_args.items() if v is not None} @@ -81,8 +89,10 @@ def mcp_resource( def mcp_prompt( name: str | None = None, + title: str | None = None, description: str | None = None, tags: set[str] | None = None, + meta: dict[str, Any] | None = None, enabled: bool | None = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to mark a method as an MCP prompt for later registration.""" @@ -90,8 +100,10 @@ def mcp_prompt( def decorator(func: Callable[..., Any]) -> Callable[..., Any]: call_args = { "name": name or get_fn_name(func), + "title": title, "description": description, "tags": tags, + "meta": meta, "enabled": enabled, } @@ -151,7 +163,6 @@ class MCPMixin: tool = Tool.from_function( fn=method, name=registration_info.get("name"), - title=registration_info.get("title"), description=registration_info.get("description"), tags=registration_info.get("tags"), annotations=registration_info.get("annotations"), @@ -195,6 +206,7 @@ class MCPMixin: fn=method, uri=registration_info["uri"], name=registration_info.get("name"), + title=registration_info.get("title"), description=registration_info.get("description"), mime_type=registration_info.get("mime_type"), tags=registration_info.get("tags"), diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 2781f3516..e79df5753 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -577,7 +577,7 @@ class OAuthProxy(OAuthProvider): self._client_storage: AsyncKeyValue = client_storage or MemoryStore() # Warn if using MemoryStore in production - if client_storage is None or isinstance(client_storage, MemoryStore): + if isinstance(client_storage, MemoryStore): logger.warning( "Using in-memory storage - all OAuth state will be lost on restart. " "For production, configure persistent storage (Redis, PostgreSQL, etc.)." diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py index c33d122ef..552654ff7 100644 --- a/src/fastmcp/server/auth/providers/jwt.py +++ b/src/fastmcp/server/auth/providers/jwt.py @@ -382,7 +382,12 @@ class JWTVerifier(TokenVerifier): claims = self.jwt.decode(token, verification_key) # Extract client ID early for logging - client_id = claims.get("client_id") or claims.get("sub") or "unknown" + client_id = ( + claims.get("client_id") + or claims.get("azp") + or claims.get("sub") + or "unknown" + ) # Validate expiration exp = claims.get("exp") diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index c1b39b805..8b46b7a0d 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -3,13 +3,16 @@ from __future__ import annotations import asyncio import copy import inspect +import logging import warnings import weakref +from asyncio.locks import Lock from collections.abc import Generator, Mapping, Sequence from contextlib import contextmanager from contextvars import ContextVar, Token from dataclasses import dataclass from enum import Enum +from logging import Logger from typing import Any, Literal, cast, get_origin, overload from mcp import LoggingLevel, ServerSession @@ -44,14 +47,21 @@ from fastmcp.server.elicitation import ( get_elicitation_schema, ) from fastmcp.server.server import FastMCP -from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.logging import _clamp_logger, get_logger from fastmcp.utilities.types import get_cached_typeadapter -logger = get_logger(__name__) +logger: Logger = get_logger(name=__name__) +to_client_logger: Logger = logger.getChild(suffix="to_client") + +# Convert all levels of server -> client messages to debug level +# This clamp can be undone at runtime by calling `_unclamp_logger` or calling +# `_clamp_logger` with a different max level. +_clamp_logger(logger=to_client_logger, max_level="DEBUG") + T = TypeVar("T", default=Any) _current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment] -_flush_lock = asyncio.Lock() +_flush_lock: Lock = asyncio.Lock() @dataclass @@ -66,6 +76,18 @@ class LogData: extra: Mapping[str, Any] | None = None +_mcp_level_to_python_level = { + "debug": logging.DEBUG, + "info": logging.INFO, + "notice": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, + "critical": logging.CRITICAL, + "alert": logging.CRITICAL, + "emergency": logging.CRITICAL, +} + + @contextmanager def set_context(context: Context) -> Generator[Context, None, None]: token = _current_context.set(context) @@ -216,6 +238,8 @@ class Context: ) -> None: """Send a log message to the client. + Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. + Args: message: Log message level: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical", @@ -223,13 +247,13 @@ class Context: logger_name: Optional logger name extra: Optional mapping for additional arguments """ - if level is None: - level = "info" data = LogData(msg=message, extra=extra) - await self.session.send_log_message( - level=level, + + await _log_to_server_and_client( data=data, - logger=logger_name, + session=self.session, + level=level or "info", + logger_name=logger_name, related_request_id=self.request_id, ) @@ -303,9 +327,14 @@ class Context: logger_name: str | None = None, extra: Mapping[str, Any] | None = None, ) -> None: - """Send a debug log message.""" + """Send a `DEBUG`-level message to the connected MCP Client. + + Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" await self.log( - level="debug", message=message, logger_name=logger_name, extra=extra + level="debug", + message=message, + logger_name=logger_name, + extra=extra, ) async def info( @@ -314,9 +343,14 @@ class Context: logger_name: str | None = None, extra: Mapping[str, Any] | None = None, ) -> None: - """Send an info log message.""" + """Send a `INFO`-level message to the connected MCP Client. + + Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" await self.log( - level="info", message=message, logger_name=logger_name, extra=extra + level="info", + message=message, + logger_name=logger_name, + extra=extra, ) async def warning( @@ -325,9 +359,14 @@ class Context: logger_name: str | None = None, extra: Mapping[str, Any] | None = None, ) -> None: - """Send a warning log message.""" + """Send a `WARNING`-level message to the connected MCP Client. + + Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" await self.log( - level="warning", message=message, logger_name=logger_name, extra=extra + level="warning", + message=message, + logger_name=logger_name, + extra=extra, ) async def error( @@ -336,9 +375,14 @@ class Context: logger_name: str | None = None, extra: Mapping[str, Any] | None = None, ) -> None: - """Send an error log message.""" + """Send a `ERROR`-level message to the connected MCP Client. + + Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" await self.log( - level="error", message=message, logger_name=logger_name, extra=extra + level="error", + message=message, + logger_name=logger_name, + extra=extra, ) async def list_roots(self) -> list[Root]: @@ -675,3 +719,31 @@ def _parse_model_preferences( raise ValueError( "model_preferences must be one of: ModelPreferences, str, list[str], or None." ) + + +async def _log_to_server_and_client( + data: LogData, + session: ServerSession, + level: LoggingLevel, + logger_name: str | None = None, + related_request_id: str | None = None, +) -> None: + """Log a message to the server and client.""" + + msg_prefix = f"Sending {level.upper()} to client" + + if logger_name: + msg_prefix += f" ({logger_name})" + + to_client_logger.log( + level=_mcp_level_to_python_level[level], + msg=f"{msg_prefix}: {data.msg}", + extra=data.extra, + ) + + await session.send_log_message( + level=level, + data=data, + logger=logger_name, + related_request_id=related_request_id, + ) diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index a5e41daf6..25264ce05 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -224,11 +224,17 @@ def create_sse_app( if middleware: server_middleware.extend(middleware) + @asynccontextmanager + async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: + async with server._lifespan_manager(): + yield + # Create and return the app app = create_base_app( routes=server_routes, middleware=server_middleware, debug=debug, + lifespan=lifespan, ) # Store the FastMCP server instance on the Starlette app state app.state.fastmcp_server = server @@ -320,8 +326,9 @@ def create_streamable_http_app( # Create a lifespan manager to start and stop the session manager @asynccontextmanager async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: - async with session_manager.run(): - yield + async with server._lifespan_manager(): + async with session_manager.run(): + yield # Create and return the app with lifespan app = create_base_app( diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py index 0b78e4866..38b99b316 100644 --- a/src/fastmcp/server/middleware/middleware.py +++ b/src/fastmcp/server/middleware/middleware.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from collections.abc import Awaitable +from collections.abc import Awaitable, Sequence from dataclasses import dataclass, field, replace from datetime import datetime, timezone from functools import partial @@ -135,15 +135,15 @@ class Middleware: async def on_request( self, - context: MiddlewareContext[mt.Request], - call_next: CallNext[mt.Request, Any], + context: MiddlewareContext[mt.Request[Any, Any]], + call_next: CallNext[mt.Request[Any, Any], Any], ) -> Any: return await call_next(context) async def on_notification( self, - context: MiddlewareContext[mt.Notification], - call_next: CallNext[mt.Notification, Any], + context: MiddlewareContext[mt.Notification[Any, Any]], + call_next: CallNext[mt.Notification[Any, Any], Any], ) -> Any: return await call_next(context) @@ -164,8 +164,10 @@ class Middleware: async def on_read_resource( self, context: MiddlewareContext[mt.ReadResourceRequestParams], - call_next: CallNext[mt.ReadResourceRequestParams, list[ReadResourceContents]], - ) -> list[ReadResourceContents]: + call_next: CallNext[ + mt.ReadResourceRequestParams, Sequence[ReadResourceContents] + ], + ) -> Sequence[ReadResourceContents]: return await call_next(context) async def on_get_prompt( @@ -178,27 +180,29 @@ class Middleware: async def on_list_tools( self, context: MiddlewareContext[mt.ListToolsRequest], - call_next: CallNext[mt.ListToolsRequest, list[Tool]], - ) -> list[Tool]: + call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]], + ) -> Sequence[Tool]: return await call_next(context) async def on_list_resources( self, context: MiddlewareContext[mt.ListResourcesRequest], - call_next: CallNext[mt.ListResourcesRequest, list[Resource]], - ) -> list[Resource]: + call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]], + ) -> Sequence[Resource]: return await call_next(context) async def on_list_resource_templates( self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], - call_next: CallNext[mt.ListResourceTemplatesRequest, list[ResourceTemplate]], - ) -> list[ResourceTemplate]: + call_next: CallNext[ + mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate] + ], + ) -> Sequence[ResourceTemplate]: return await call_next(context) async def on_list_prompts( self, context: MiddlewareContext[mt.ListPromptsRequest], - call_next: CallNext[mt.ListPromptsRequest, list[Prompt]], - ) -> list[Prompt]: + call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]], + ) -> Sequence[Prompt]: return await call_next(context) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 81290e5cd..6c91e870e 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -89,6 +89,10 @@ Transport = Literal["stdio", "http", "sse", "streamable-http"] # Compiled URI parsing regex to split a URI into protocol and path components URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$") +LifespanCallable = Callable[ + ["FastMCP[LifespanResultT]"], AbstractAsyncContextManager[LifespanResultT] +] + @asynccontextmanager async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]: @@ -98,26 +102,31 @@ async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[An server: The server instance this lifespan is managing Returns: - An empty context object + An empty dictionary as the lifespan result. """ yield {} -def _lifespan_wrapper( - app: FastMCP[LifespanResultT], - lifespan: Callable[ - [FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT] - ], +def _lifespan_proxy( + fastmcp_server: FastMCP[LifespanResultT], ) -> Callable[ [LowLevelServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT] ]: @asynccontextmanager async def wrap( - s: LowLevelServer[LifespanResultT], + low_level_server: LowLevelServer[LifespanResultT], ) -> AsyncIterator[LifespanResultT]: - async with AsyncExitStack() as stack: - context = await stack.enter_async_context(lifespan(app)) - yield context + if fastmcp_server._lifespan is default_lifespan: + yield {} + return + + if not fastmcp_server._lifespan_result_set: + raise RuntimeError( + "FastMCP server has a lifespan defined but no lifespan result is set, which means the server's context manager was not entered. " + + " Are you running the server in a way that supports lifespans? If so, please file an issue at https://github.com/jlowin/fastmcp/issues." + ) + + yield fastmcp_server._lifespan_result return wrap @@ -131,13 +140,7 @@ class FastMCP(Generic[LifespanResultT]): version: str | None = None, auth: AuthProvider | None | NotSetT = NotSet, middleware: list[Middleware] | None = None, - lifespan: ( - Callable[ - [FastMCP[LifespanResultT]], - AbstractAsyncContextManager[LifespanResultT], - ] - | None - ) = None, + lifespan: LifespanCallable | None = None, dependencies: list[str] | None = None, resource_prefix_format: Literal["protocol", "path"] | None = None, mask_error_details: bool | None = None, @@ -150,6 +153,7 @@ class FastMCP(Generic[LifespanResultT]): on_duplicate_tools: DuplicateBehavior | None = None, on_duplicate_resources: DuplicateBehavior | None = None, on_duplicate_prompts: DuplicateBehavior | None = None, + strict_input_validation: bool | None = None, # --- # --- # --- The following arguments are DEPRECATED --- @@ -188,18 +192,17 @@ class FastMCP(Generic[LifespanResultT]): ) self._tool_serializer = tool_serializer - if lifespan is None: - self._has_lifespan = False - lifespan = default_lifespan - else: - self._has_lifespan = True + self._lifespan: LifespanCallable[LifespanResultT] = lifespan or default_lifespan + self._lifespan_result: LifespanResultT | None = None + self._lifespan_result_set = False + # Generate random ID if no name provided self._mcp_server = LowLevelServer[LifespanResultT]( fastmcp=self, name=name or self.generate_name(), version=version or fastmcp.__version__, instructions=instructions, - lifespan=_lifespan_wrapper(self, lifespan), + lifespan=_lifespan_proxy(fastmcp_server=self), ) # if auth is `NotSet`, try to create a provider from the environment @@ -219,6 +222,11 @@ class FastMCP(Generic[LifespanResultT]): self.include_tags = include_tags self.exclude_tags = exclude_tags + self.strict_input_validation = ( + strict_input_validation + if strict_input_validation is not None + else fastmcp.settings.strict_input_validation + ) self.middleware = middleware or [] @@ -334,6 +342,27 @@ class FastMCP(Generic[LifespanResultT]): def version(self) -> str | None: return self._mcp_server.version + @asynccontextmanager + async def _lifespan_manager(self) -> AsyncIterator[None]: + if self._lifespan_result_set: + yield + return + + async with self._lifespan(self) as lifespan_result: + self._lifespan_result = lifespan_result + self._lifespan_result_set = True + + async with AsyncExitStack[bool | None]() as stack: + for server in self._mounted_servers: + await stack.enter_async_context( + cm=server.server._lifespan_manager() + ) + + yield + + self._lifespan_result_set = False + self._lifespan_result = None + async def run_async( self, transport: Transport | None = None, @@ -391,7 +420,9 @@ class FastMCP(Generic[LifespanResultT]): self._mcp_server.list_resources()(self._list_resources_mcp) self._mcp_server.list_resource_templates()(self._list_resource_templates_mcp) self._mcp_server.list_prompts()(self._list_prompts_mcp) - self._mcp_server.call_tool()(self._call_tool_mcp) + self._mcp_server.call_tool(validate_input=self.strict_input_validation)( + self._call_tool_mcp + ) self._mcp_server.read_resource()(self._read_resource_mcp) self._mcp_server.get_prompt()(self._get_prompt_mcp) @@ -641,7 +672,11 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, self._list_tools) + return list( + await self._apply_middleware( + context=mw_context, call_next=self._list_tools + ) + ) async def _list_tools( self, @@ -721,7 +756,11 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, self._list_resources) + return list( + await self._apply_middleware( + context=mw_context, call_next=self._list_resources + ) + ) async def _list_resources( self, @@ -811,8 +850,10 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware( - mw_context, self._list_resource_templates + return list( + await self._apply_middleware( + context=mw_context, call_next=self._list_resource_templates + ) ) async def _list_resource_templates( @@ -907,7 +948,11 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, self._list_prompts) + return list( + await self._apply_middleware( + context=mw_context, call_next=self._list_prompts + ) + ) async def _list_prompts( self, @@ -1002,7 +1047,9 @@ class FastMCP(Generic[LifespanResultT]): method="tools/call", fastmcp_context=fastmcp.server.dependencies.get_context(), ) - return await self._apply_middleware(mw_context, self._call_tool) + return await self._apply_middleware( + context=mw_context, call_next=self._call_tool + ) async def _call_tool( self, @@ -1056,7 +1103,9 @@ class FastMCP(Generic[LifespanResultT]): async with fastmcp.server.context.Context(fastmcp=self): try: - return await self._read_resource_middleware(uri) + return list[ReadResourceContents]( + await self._read_resource_middleware(uri) + ) except DisabledError: # convert to NotFoundError to avoid leaking resource presence raise NotFoundError(f"Unknown resource: {str(uri)!r}") @@ -1085,7 +1134,11 @@ class FastMCP(Generic[LifespanResultT]): method="resources/read", fastmcp_context=fastmcp.server.dependencies.get_context(), ) - return await self._apply_middleware(mw_context, self._read_resource) + return list( + await self._apply_middleware( + context=mw_context, call_next=self._read_resource + ) + ) async def _read_resource( self, @@ -1114,7 +1167,7 @@ class FastMCP(Generic[LifespanResultT]): if not self._should_enable_component(resource): # Parent filter blocks this resource, continue searching continue - result = await mounted.server._read_resource_middleware(key) + result = list(await mounted.server._read_resource_middleware(key)) return result except NotFoundError: continue @@ -1173,7 +1226,9 @@ class FastMCP(Generic[LifespanResultT]): method="prompts/get", fastmcp_context=fastmcp.server.dependencies.get_context(), ) - return await self._apply_middleware(mw_context, self._get_prompt) + return await self._apply_middleware( + context=mw_context, call_next=self._get_prompt + ) async def _get_prompt( self, @@ -1856,15 +1911,18 @@ class FastMCP(Generic[LifespanResultT]): ) with temporary_log_level(log_level): - async with stdio_server() as (read_stream, write_stream): - logger.info(f"Starting MCP server {self.name!r} with transport 'stdio'") - await self._mcp_server.run( - read_stream, - write_stream, - self._mcp_server.create_initialization_options( - NotificationOptions(tools_changed=True) - ), - ) + async with self._lifespan_manager(): + async with stdio_server() as (read_stream, write_stream): + logger.info( + f"Starting MCP server {self.name!r} with transport 'stdio'" + ) + await self._mcp_server.run( + read_stream, + write_stream, + self._mcp_server.create_initialization_options( + NotificationOptions(tools_changed=True) + ), + ) async def run_http_async( self, @@ -1935,14 +1993,15 @@ class FastMCP(Generic[LifespanResultT]): config_kwargs["log_level"] = default_log_level_to_use with temporary_log_level(log_level): - config = uvicorn.Config(app, host=host, port=port, **config_kwargs) - server = uvicorn.Server(config) - path = app.state.path.lstrip("/") # type: ignore - logger.info( - f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}" - ) + async with self._lifespan_manager(): + config = uvicorn.Config(app, host=host, port=port, **config_kwargs) + server = uvicorn.Server(config) + path = app.state.path.lstrip("/") # type: ignore + logger.info( + f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}" + ) - await server.serve() + await server.serve() async def run_sse_async( self, @@ -2204,7 +2263,7 @@ class FastMCP(Generic[LifespanResultT]): # if as_proxy is not specified and the server has a custom lifespan, # we should treat it as a proxy if as_proxy is None: - as_proxy = server._has_lifespan + as_proxy = server._lifespan != default_lifespan if as_proxy and not isinstance(server, FastMCPProxy): server = FastMCP.as_proxy(server) @@ -2338,6 +2397,15 @@ class FastMCP(Generic[LifespanResultT]): prompt = prompt.model_copy(key=f"{prefix}_{key}") self._prompt_manager.add_prompt(prompt) + if server._lifespan != default_lifespan: + from warnings import warn + + warn( + message="When importing from a server with a lifespan, the lifespan from the imported server will not be used.", + category=RuntimeWarning, + stacklevel=2, + ) + if prefix: logger.debug( f"[{self.name}] Imported server {server.name} with prefix '{prefix}'" diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index ac8ce6df1..ae4b5e617 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -194,7 +194,6 @@ class Settings(BaseSettings): client_raise_first_exceptiongroup_error: Annotated[ bool, Field( - default=True, description=inspect.cleandoc( """ Many MCP components operate in anyio taskgroups, and raise @@ -210,7 +209,6 @@ class Settings(BaseSettings): resource_prefix_format: Annotated[ Literal["protocol", "path"], Field( - default="path", description=inspect.cleandoc( """ When perfixing a resource URI, either use path formatting (resource://prefix/path) @@ -240,7 +238,6 @@ class Settings(BaseSettings): mask_error_details: Annotated[ bool, Field( - default=False, description=inspect.cleandoc( """ If True, error details from user-supplied functions (tool, resource, prompt) @@ -253,6 +250,22 @@ class Settings(BaseSettings): ), ] = False + strict_input_validation: Annotated[ + bool, + Field( + description=inspect.cleandoc( + """ + If True, tool inputs are strictly validated against the input + JSON schema. For example, providing the string \"10\" to an + integer field will raise an error. If False, compatible inputs + will be coerced to match the schema, which can increase + compatibility. For example, providing the string \"10\" to an + integer field will be coerced to 10. Defaults to False. + """ + ), + ), + ] = False + server_dependencies: list[str] = Field( default_factory=list, description="List of dependencies to install in the server environment", @@ -298,7 +311,6 @@ class Settings(BaseSettings): include_tags: Annotated[ set[str] | None, Field( - default=None, description=inspect.cleandoc( """ If provided, only components that match these tags will be @@ -311,7 +323,6 @@ class Settings(BaseSettings): exclude_tags: Annotated[ set[str] | None, Field( - default=None, description=inspect.cleandoc( """ If provided, components that match these tags will be excluded @@ -325,7 +336,6 @@ class Settings(BaseSettings): include_fastmcp_meta: Annotated[ bool, Field( - default=True, description=inspect.cleandoc( """ Whether to include FastMCP meta in the server's MCP responses. @@ -340,7 +350,6 @@ class Settings(BaseSettings): mounted_components_raise_on_load_error: Annotated[ bool, Field( - default=False, description=inspect.cleandoc( """ If True, errors encountered when loading mounted components (tools, resources, prompts) @@ -354,7 +363,6 @@ class Settings(BaseSettings): show_cli_banner: Annotated[ bool, Field( - default=True, description=inspect.cleandoc( """ If True, the server banner will be displayed when running the server via CLI. diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index cd74dc18e..f1ea00cbc 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -5,6 +5,7 @@ from collections.abc import Callable from typing import Any from mcp.types import ToolAnnotations +from pydantic import ValidationError from fastmcp import settings from fastmcp.exceptions import NotFoundError, ToolError @@ -153,6 +154,9 @@ class ToolManager: tool = await self.get_tool(key) try: return await tool.run(arguments) + except ValidationError as e: + logger.exception(f"Error validating tool {key!r}: {e}") + raise e except ToolError as e: logger.exception(f"Error calling tool {key!r}") raise e diff --git a/src/fastmcp/utilities/logging.py b/src/fastmcp/utilities/logging.py index ec7f47023..b6c83fa4a 100644 --- a/src/fastmcp/utilities/logging.py +++ b/src/fastmcp/utilities/logging.py @@ -6,6 +6,7 @@ from typing import Any, Literal, cast from rich.console import Console from rich.logging import RichHandler +from typing_extensions import override import fastmcp @@ -19,7 +20,10 @@ def get_logger(name: str) -> logging.Logger: Returns: a configured logger instance """ - return logging.getLogger(f"fastmcp.{name}") + if name.startswith("fastmcp."): + return logging.getLogger(name=name) + + return logging.getLogger(name=f"fastmcp.{name}") def configure_logging( @@ -141,3 +145,86 @@ def temporary_log_level( ) else: yield + + +class _ClampedLogFilter(logging.Filter): + min_level: tuple[int, str] | None + max_level: tuple[int, str] | None + + def __init__( + self, + min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + | None = None, + max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + | None = None, + ): + self.min_level = None + self.max_level = None + + if min_level_no := self._level_to_no(level=min_level): + self.min_level = (min_level_no, str(min_level)) + if max_level_no := self._level_to_no(level=max_level): + self.max_level = (max_level_no, str(max_level)) + + super().__init__() + + def _level_to_no( + self, level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None + ) -> int | None: + if level == "DEBUG": + return logging.DEBUG + elif level == "INFO": + return logging.INFO + elif level == "WARNING": + return logging.WARNING + elif level == "ERROR": + return logging.ERROR + elif level == "CRITICAL": + return logging.CRITICAL + else: + return None + + @override + def filter(self, record: logging.LogRecord) -> bool: + if self.max_level: + max_level_no, max_level_name = self.max_level + + if record.levelno > max_level_no: + record.levelno = max_level_no + record.levelname = max_level_name + return True + + if self.min_level: + min_level_no, min_level_name = self.min_level + if record.levelno < min_level_no: + record.levelno = min_level_no + record.levelname = min_level_name + return True + + return True + + +def _clamp_logger( + logger: logging.Logger, + min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, + max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, +) -> None: + """Clamp the logger to a minimum and maximum level. + + If min_level is provided, messages logged at a lower level than `min_level` will have their level increased to `min_level`. + If max_level is provided, messages logged at a higher level than `max_level` will have their level decreased to `max_level`. + + Args: + min_level: The lower bound of the clamp + max_level: The upper bound of the clamp + """ + _unclamp_logger(logger=logger) + + logger.addFilter(filter=_ClampedLogFilter(min_level=min_level, max_level=max_level)) + + +def _unclamp_logger(logger: logging.Logger) -> None: + """Remove all clamped log filters from the logger.""" + for filter in logger.filters[:]: + if isinstance(filter, _ClampedLogFilter): + logger.removeFilter(filter) diff --git a/src/fastmcp/utilities/ui.py b/src/fastmcp/utilities/ui.py index 0d5c3bafd..e5a8429a2 100644 --- a/src/fastmcp/utilities/ui.py +++ b/src/fastmcp/utilities/ui.py @@ -7,6 +7,8 @@ consent pages, and other user-facing interfaces. from __future__ import annotations +import html + from starlette.responses import HTMLResponse # FastMCP branding @@ -339,6 +341,7 @@ def create_page( Returns: Complete HTML page as string """ + title = html.escape(title) return f""" @@ -375,6 +378,7 @@ def create_status_message(message: str, is_success: bool = True) -> str: Returns: HTML for status message """ + message = html.escape(message) icon = "✓" if is_success else "✕" icon_class = "success" if is_success else "error" @@ -400,6 +404,7 @@ def create_info_box( Returns: HTML for info box """ + content = html.escape(content) classes = ["info-box"] if is_error: classes.append("error") @@ -422,8 +427,8 @@ def create_detail_box(rows: list[tuple[str, str]]) -> str: rows_html = "\n".join( f"""
-
{label}:
-
{value}
+
{html.escape(label)}:
+
{html.escape(value)}
""" for label, value in rows diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 01e52ef0e..10e86b8a7 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -416,7 +416,6 @@ class TestWindowsSpecific: ["npx.cmd", "--version"], check=True, capture_output=True, - shell=True, ) @patch("subprocess.run") diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py index df40a7eab..a1af8241d 100644 --- a/tests/cli/test_cursor.py +++ b/tests/cli/test_cursor.py @@ -145,7 +145,7 @@ class TestOpenDeeplink: assert result is True mock_run.assert_called_once_with( - ["start", "cursor://test"], shell=True, check=True, capture_output=True + ["cmd", "/c", "start", "cursor://test"], check=True, capture_output=True ) @patch("subprocess.run") diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index 7773684a7..e067568fd 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -274,6 +274,210 @@ mcp = fastmcp.FastMCP("TestServer") assert exc_info.value.code == 1 +class TestV1ServerAsync: + """Test FastMCP 1.x server async support.""" + + async def test_run_v1_server_stdio(self, tmp_path): + """Test that v1 server uses async stdio method.""" + from unittest.mock import AsyncMock, patch + + from mcp.server.fastmcp import FastMCP as FastMCP1x + + from fastmcp.cli.run import run_command + + # Create a v1 FastMCP server file with both sync and async tools + test_file = tmp_path / "v1_server.py" + test_file.write_text(""" +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("V1Server") + +@mcp.tool() +def sync_echo(text: str) -> str: + '''Sync tool for testing''' + return f"sync: {text}" + +@mcp.tool() +async def async_echo(text: str) -> str: + '''Async tool for testing''' + return f"async: {text}" +""") + + # Mock the async run method + with patch.object( + FastMCP1x, "run_stdio_async", new_callable=AsyncMock + ) as run_mock: + await run_command(str(test_file), transport="stdio") + run_mock.assert_called_once() + + async def test_run_v1_server_http(self, tmp_path): + """Test that v1 server uses async http method.""" + from unittest.mock import AsyncMock, patch + + from mcp.server.fastmcp import FastMCP as FastMCP1x + + from fastmcp.cli.run import run_command + + # Create a v1 FastMCP server file with both sync and async tools + test_file = tmp_path / "v1_server.py" + test_file.write_text(""" +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("V1Server") + +@mcp.tool() +def sync_echo(text: str) -> str: + '''Sync tool for testing''' + return f"sync: {text}" + +@mcp.tool() +async def async_echo(text: str) -> str: + '''Async tool for testing''' + return f"async: {text}" +""") + + # Mock the async run method + with patch.object( + FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock + ) as run_mock: + await run_command(str(test_file), transport="http") + run_mock.assert_called_once() + + async def test_run_v1_server_streamable_http(self, tmp_path): + """Test that v1 server uses async streamable-http method.""" + from unittest.mock import AsyncMock, patch + + from mcp.server.fastmcp import FastMCP as FastMCP1x + + from fastmcp.cli.run import run_command + + # Create a v1 FastMCP server file with both sync and async tools + test_file = tmp_path / "v1_server.py" + test_file.write_text(""" +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("V1Server") + +@mcp.tool() +def sync_echo(text: str) -> str: + '''Sync tool for testing''' + return f"sync: {text}" + +@mcp.tool() +async def async_echo(text: str) -> str: + '''Async tool for testing''' + return f"async: {text}" +""") + + # Mock the async run method + with patch.object( + FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock + ) as run_mock: + await run_command(str(test_file), transport="streamable-http") + run_mock.assert_called_once() + + async def test_run_v1_server_sse(self, tmp_path): + """Test that v1 server uses async sse method.""" + from unittest.mock import AsyncMock, patch + + from mcp.server.fastmcp import FastMCP as FastMCP1x + + from fastmcp.cli.run import run_command + + # Create a v1 FastMCP server file with both sync and async tools + test_file = tmp_path / "v1_server.py" + test_file.write_text(""" +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("V1Server") + +@mcp.tool() +def sync_echo(text: str) -> str: + '''Sync tool for testing''' + return f"sync: {text}" + +@mcp.tool() +async def async_echo(text: str) -> str: + '''Async tool for testing''' + return f"async: {text}" +""") + + # Mock the async run method + with patch.object( + FastMCP1x, "run_sse_async", new_callable=AsyncMock + ) as run_mock: + await run_command(str(test_file), transport="sse") + run_mock.assert_called_once() + + async def test_run_v1_server_default_transport(self, tmp_path): + """Test that v1 server uses streamable-http by default.""" + from unittest.mock import AsyncMock, patch + + from mcp.server.fastmcp import FastMCP as FastMCP1x + + from fastmcp.cli.run import run_command + + # Create a v1 FastMCP server file with both sync and async tools + test_file = tmp_path / "v1_server.py" + test_file.write_text(""" +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("V1Server") + +@mcp.tool() +def sync_echo(text: str) -> str: + '''Sync tool for testing''' + return f"sync: {text}" + +@mcp.tool() +async def async_echo(text: str) -> str: + '''Async tool for testing''' + return f"async: {text}" +""") + + # Mock the async run method + with patch.object( + FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock + ) as run_mock: + await run_command(str(test_file)) + run_mock.assert_called_once() + + async def test_run_v1_server_with_host_port(self, tmp_path): + """Test that v1 server receives host/port settings.""" + from unittest.mock import AsyncMock, patch + + from mcp.server.fastmcp import FastMCP as FastMCP1x + + from fastmcp.cli.run import run_command + + # Create a v1 FastMCP server file with both sync and async tools + test_file = tmp_path / "v1_server.py" + test_file.write_text(""" +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("V1Server") + +@mcp.tool() +def sync_echo(text: str) -> str: + '''Sync tool for testing''' + return f"sync: {text}" + +@mcp.tool() +async def async_echo(text: str) -> str: + '''Async tool for testing''' + return f"async: {text}" +""") + + # Mock the async run method + with patch.object( + FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock + ) as run_mock: + await run_command( + str(test_file), transport="http", host="0.0.0.0", port=9000 + ) + run_mock.assert_called_once() + + class TestSkipSource: """Test the --skip-source functionality.""" diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 009103233..a32e92073 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -678,7 +678,8 @@ class TestErrorHandling: async with Client(transport=FastMCPTransport(mcp)) as client: result = await client.call_tool_mcp("validated_tool", {"x": "abc"}) assert result.isError - assert "'abc' is not of type 'integer'" in result.content[0].text # type: ignore[attr-defined] + # Pydantic validation error message should NOT be masked + assert "Input should be a valid integer" in result.content[0].text # type: ignore[attr-defined] async def test_specific_tool_errors_are_sent_to_client(self): mcp = FastMCP("TestServer") diff --git a/tests/client/test_logs.py b/tests/client/test_logs.py index d98ac725b..f7f728515 100644 --- a/tests/client/test_logs.py +++ b/tests/client/test_logs.py @@ -101,7 +101,7 @@ class TestDefaultLogHandler: from fastmcp.client.logging import default_log_handler - with patch("fastmcp.client.logging.logger") as mock_logger: + with patch("fastmcp.client.logging.from_server_logger") as mock_logger: # Set up mock methods mock_logger.debug = MagicMock() mock_logger.info = MagicMock() @@ -141,7 +141,8 @@ class TestDefaultLogHandler: # Verify correct method was called expected_method.assert_called_once_with( - f"Server log: [test.logger] {msg}", extra={"test_key": "test_value"} + msg=f"Received {level.upper()} from server (test.logger): {msg}", + extra={"test_key": "test_value"}, ) async def test_default_handler_without_logger_name(self): @@ -152,7 +153,7 @@ class TestDefaultLogHandler: from fastmcp.client.logging import default_log_handler - with patch("fastmcp.client.logging.logger") as mock_logger: + with patch("fastmcp.client.logging.from_server_logger") as mock_logger: mock_logger.info = MagicMock() log_msg = LoggingMessageNotificationParams( @@ -164,7 +165,7 @@ class TestDefaultLogHandler: await default_log_handler(log_msg) mock_logger.info.assert_called_once_with( - "Server log: Message without logger", extra={} + msg="Received INFO from server: Message without logger", extra={} ) async def test_default_handler_with_missing_msg(self): @@ -175,7 +176,7 @@ class TestDefaultLogHandler: from fastmcp.client.logging import default_log_handler - with patch("fastmcp.client.logging.logger") as mock_logger: + with patch("fastmcp.client.logging.from_server_logger") as mock_logger: mock_logger.info = MagicMock() log_msg = LoggingMessageNotificationParams( @@ -189,5 +190,5 @@ class TestDefaultLogHandler: # Should use str(message) as fallback mock_logger.info.assert_called_once() call_args = mock_logger.info.call_args - assert "Server log:" in call_args[0][0] + assert "Received INFO from server" in call_args[1]["msg"] assert call_args[1]["extra"] == {"key": "value"} diff --git a/tests/client/test_oauth_callback_xss.py b/tests/client/test_oauth_callback_xss.py new file mode 100644 index 000000000..626fc7798 --- /dev/null +++ b/tests/client/test_oauth_callback_xss.py @@ -0,0 +1,159 @@ +"""Comprehensive XSS protection tests for OAuth callback HTML rendering.""" + +import pytest + +from fastmcp.client.oauth_callback import create_callback_html +from fastmcp.utilities.ui import ( + create_detail_box, + create_info_box, + create_page, + create_status_message, +) + + +def test_ui_create_page_escapes_title(): + """Test that page title is properly escaped.""" + xss_title = "" + html = create_page("content", title=xss_title) + assert "<script>alert(1)</script>" in html + assert "" not in html + + +def test_ui_create_status_message_escapes(): + """Test that status messages are properly escaped.""" + xss_message = "" + html = create_status_message(xss_message) + assert "<img src=x onerror=alert(1)>" in html + assert "" not in html + + +def test_ui_create_info_box_escapes(): + """Test that info box content is properly escaped.""" + xss_content = "" + html = create_info_box(xss_content) + assert "<iframe" in html + assert "