From 26e2414b48c403d4e5e042067ffecb49d8edd763 Mon Sep 17 00:00:00 2001 From: Ankesh Date: Thu, 9 Oct 2025 15:36:15 +0530 Subject: [PATCH 01/17] feat: Add title, annotations and meta to mixin decorators --- docs/servers/prompts.mdx | 6 +- src/fastmcp/contrib/mcp_mixin/README.md | 33 ++++++++++- src/fastmcp/contrib/mcp_mixin/mcp_mixin.py | 16 +++++- tests/contrib/test_mcp_mixin.py | 67 ++++++++++++++++++++++ 4 files changed, 118 insertions(+), 4 deletions(-) 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/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/tests/contrib/test_mcp_mixin.py b/tests/contrib/test_mcp_mixin.py index a39b293e3..04ad5eb69 100644 --- a/tests/contrib/test_mcp_mixin.py +++ b/tests/contrib/test_mcp_mixin.py @@ -253,3 +253,70 @@ class TestMCPMixin: assert f"cust{_DEFAULT_SEPARATOR_TOOL}tool_cust" not in tools assert f"cust{_DEFAULT_SEPARATOR_RESOURCE}res://cust" not in resources assert f"cust{_DEFAULT_SEPARATOR_PROMPT}prompt_cust" not in prompts + + async def test_tool_with_title_and_meta(self): + """Test that title (via annotations) and meta arguments are properly passed through.""" + from mcp.types import ToolAnnotations + + mcp = FastMCP() + + class MyToolWithMeta(MCPMixin): + @mcp_tool( + annotations=ToolAnnotations(title="My Tool Title"), + meta={"version": "1.0", "author": "test"}, + ) + def sample_tool(self): + pass + + instance = MyToolWithMeta() + instance.register_tools(mcp) + + registered_tools = await mcp.get_tools() + tool = registered_tools["sample_tool"] + + assert tool.annotations is not None + assert tool.annotations.title == "My Tool Title" + assert tool.meta == {"version": "1.0", "author": "test"} + + async def test_resource_with_meta(self): + """Test that meta argument is properly passed through for resources.""" + mcp = FastMCP() + + class MyResourceWithMeta(MCPMixin): + @mcp_resource( + uri="test://resource", + title="My Resource Title", + meta={"category": "data", "internal": True}, + ) + def sample_resource(self): + pass + + instance = MyResourceWithMeta() + instance.register_resources(mcp) + + registered_resources = await mcp.get_resources() + resource = registered_resources["test://resource"] + + assert resource.meta == {"category": "data", "internal": True} + assert resource.title == "My Resource Title" + + async def test_prompt_with_title_and_meta(self): + """Test that title and meta arguments are properly passed through for prompts.""" + mcp = FastMCP() + + class MyPromptWithMeta(MCPMixin): + @mcp_prompt( + title="My Prompt Title", + meta={"priority": "high", "category": "analysis"}, + ) + def sample_prompt(self): + pass + + instance = MyPromptWithMeta() + instance.register_prompts(mcp) + + prompts = await mcp.get_prompts() + prompt = prompts["sample_prompt"] + + assert prompt.title == "My Prompt Title" + assert prompt.meta == {"priority": "high", "category": "analysis"} From d1f7c74cdeb5e276ee6bc59da2cf4eaef8bb56c4 Mon Sep 17 00:00:00 2001 From: William Easton Date: Fri, 10 Oct 2025 12:30:27 -0400 Subject: [PATCH 02/17] Add warnings regarding memory store --- src/fastmcp/client/auth/oauth.py | 8 ++++++++ src/fastmcp/server/auth/oauth_proxy.py | 8 ++++++++ 2 files changed, 16 insertions(+) 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/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index 998aa05a6..d0e0994fd 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -388,6 +388,14 @@ class OAuthProxy(OAuthProvider): self._client_storage: AsyncKeyValue = client_storage or MemoryStore() + if isinstance(self._client_storage, MemoryStore): + from warnings import warn + + warn( + message="Using in-memory client storage is not recommended for production use -- " + + "clients will be lost on server restart which may require manual clean-up of oauth information on the client." + ) + self._client_store = PydanticAdapter[ProxyDCRClient]( key_value=self._client_storage, pydantic_model=ProxyDCRClient, From e196384a6a116caa947ff86191fa03e559dfe342 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 23:59:57 +0000 Subject: [PATCH 03/17] Change middleware return types from list to Sequence This fixes covariance issues in middleware method signatures by using Sequence instead of list for return type annotations. Lists are invariant while Sequences are covariant, allowing middleware subclasses to properly type their return values. Fixes #2055 Co-authored-by: William Easton --- src/fastmcp/server/middleware/middleware.py | 26 ++++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py index 0b78e4866..ba2d25c23 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 @@ -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) From 05a73e16f1b9055291cd1f67d4d1d67a56efcaae Mon Sep 17 00:00:00 2001 From: Marcin Jan Puhacz Date: Sun, 12 Oct 2025 00:33:06 +0200 Subject: [PATCH 04/17] feat: expose errlog on stdio transport (#1991) Co-authored-by: William Easton Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- src/fastmcp/client/transports.py | 47 ++++++++++++- tests/client/test_stdio.py | 109 +++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 1016d35e0..3664c21d3 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 diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index a9471d79b..39fd7650f 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -253,3 +253,112 @@ class TestKeepAlive: with pytest.raises(RuntimeError, match="Client failed to connect"): async with client: pass + + +class TestLogFile: + @pytest.fixture + def stdio_script_with_stderr(self, tmp_path): + script = inspect.cleandoc(''' + import sys + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def write_error(message: str) -> str: + """Writes a message to stderr and returns it""" + print(message, file=sys.stderr, flush=True) + return message + + if __name__ == "__main__": + mcp.run() + ''') + script_file = tmp_path / "stderr_script.py" + script_file.write_text(script) + return script_file + + async def test_log_file_parameter_accepted_by_stdio_transport(self, tmp_path): + """Test that log_file parameter can be set on StdioTransport""" + log_file_path = tmp_path / "errors.log" + transport = StdioTransport( + command="python", args=["script.py"], log_file=log_file_path + ) + assert transport.log_file == log_file_path + + async def test_log_file_parameter_accepted_by_python_stdio_transport( + self, tmp_path, stdio_script_with_stderr + ): + """Test that log_file parameter can be set on PythonStdioTransport""" + log_file_path = tmp_path / "errors.log" + transport = PythonStdioTransport( + script_path=stdio_script_with_stderr, log_file=log_file_path + ) + assert transport.log_file == log_file_path + + async def test_log_file_parameter_accepts_textio(self, tmp_path): + """Test that log_file parameter can accept a TextIO object""" + log_file_path = tmp_path / "errors.log" + with open(log_file_path, "w") as log_file: + transport = StdioTransport( + command="python", args=["script.py"], log_file=log_file + ) + assert transport.log_file == log_file + + async def test_log_file_captures_stderr_output_with_path( + self, tmp_path, stdio_script_with_stderr + ): + """Test that stderr output is written to the log_file when using Path""" + log_file_path = tmp_path / "errors.log" + + transport = PythonStdioTransport( + script_path=stdio_script_with_stderr, log_file=log_file_path + ) + client = Client(transport=transport) + + async with client: + await client.call_tool("write_error", {"message": "Test error message"}) + + # Need to wait a bit for stderr to flush + await asyncio.sleep(0.1) + + content = log_file_path.read_text() + assert "Test error message" in content + + async def test_log_file_captures_stderr_output_with_textio( + self, tmp_path, stdio_script_with_stderr + ): + """Test that stderr output is written to the log_file when using TextIO""" + log_file_path = tmp_path / "errors.log" + + with open(log_file_path, "w") as log_file: + transport = PythonStdioTransport( + script_path=stdio_script_with_stderr, log_file=log_file + ) + client = Client(transport=transport) + + async with client: + await client.call_tool( + "write_error", {"message": "Test error with TextIO"} + ) + + # Need to wait a bit for stderr to flush + await asyncio.sleep(0.1) + + content = log_file_path.read_text() + assert "Test error with TextIO" in content + + async def test_log_file_none_uses_default_behavior( + self, tmp_path, stdio_script_with_stderr + ): + """Test that log_file=None uses default stderr handling""" + transport = PythonStdioTransport( + script_path=stdio_script_with_stderr, log_file=None + ) + client = Client(transport=transport) + + async with client: + # Should work without error even without explicit log_file + result = await client.call_tool( + "write_error", {"message": "Default stderr"} + ) + assert result.data == "Default stderr" From a901d5b07d573711fc956a9efa65c7f7c30a0764 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 12 Oct 2025 08:36:57 -0400 Subject: [PATCH 05/17] Delete outdated root docs (#2075) --- README_OPENAPI.md | 246 ---------------------------------------------- Windows_Notes.md | 58 ----------- 2 files changed, 304 deletions(-) delete mode 100644 README_OPENAPI.md delete mode 100644 Windows_Notes.md 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 - - From 6bd3f85cb26e7a2705d18dde947be474f032705f Mon Sep 17 00:00:00 2001 From: William Easton Date: Sun, 12 Oct 2025 09:34:25 -0400 Subject: [PATCH 06/17] Add't typing changes for middleware --- src/fastmcp/server/middleware/middleware.py | 8 ++-- src/fastmcp/server/server.py | 46 ++++++++++++++++----- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py index ba2d25c23..38b99b316 100644 --- a/src/fastmcp/server/middleware/middleware.py +++ b/src/fastmcp/server/middleware/middleware.py @@ -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) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 81290e5cd..64227babd 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -641,7 +641,11 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, self._list_tools) + return list[Tool]( + await self._apply_middleware( + context=mw_context, call_next=self._list_tools + ) + ) async def _list_tools( self, @@ -721,7 +725,11 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, self._list_resources) + return list[Resource]( + await self._apply_middleware( + context=mw_context, call_next=self._list_resources + ) + ) async def _list_resources( self, @@ -811,8 +819,10 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware( - mw_context, self._list_resource_templates + return list[ResourceTemplate]( + await self._apply_middleware( + context=mw_context, call_next=self._list_resource_templates + ) ) async def _list_resource_templates( @@ -907,7 +917,11 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return await self._apply_middleware(mw_context, self._list_prompts) + return list[Prompt]( + await self._apply_middleware( + context=mw_context, call_next=self._list_prompts + ) + ) async def _list_prompts( self, @@ -1002,7 +1016,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 +1072,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 +1103,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[ReadResourceContents]( + await self._apply_middleware( + context=mw_context, call_next=self._read_resource + ) + ) async def _read_resource( self, @@ -1114,7 +1136,9 @@ 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[ReadResourceContents]( + await mounted.server._read_resource_middleware(key) + ) return result except NotFoundError: continue @@ -1173,7 +1197,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, From 16ebb666d6bc320849c326a88ff4f172c921ef8c Mon Sep 17 00:00:00 2001 From: William Easton Date: Sun, 12 Oct 2025 21:34:29 -0400 Subject: [PATCH 07/17] stop subscripting list init --- src/fastmcp/server/server.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 64227babd..97f14a7c9 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -641,7 +641,7 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return list[Tool]( + return list( await self._apply_middleware( context=mw_context, call_next=self._list_tools ) @@ -725,7 +725,7 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return list[Resource]( + return list( await self._apply_middleware( context=mw_context, call_next=self._list_resources ) @@ -819,7 +819,7 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return list[ResourceTemplate]( + return list( await self._apply_middleware( context=mw_context, call_next=self._list_resource_templates ) @@ -917,7 +917,7 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply the middleware chain. - return list[Prompt]( + return list( await self._apply_middleware( context=mw_context, call_next=self._list_prompts ) @@ -1103,7 +1103,7 @@ class FastMCP(Generic[LifespanResultT]): method="resources/read", fastmcp_context=fastmcp.server.dependencies.get_context(), ) - return list[ReadResourceContents]( + return list( await self._apply_middleware( context=mw_context, call_next=self._read_resource ) From 7e873168172fd59c34d8aedda0efe90a0f98aa30 Mon Sep 17 00:00:00 2001 From: William Easton Date: Mon, 13 Oct 2025 10:23:23 -0500 Subject: [PATCH 08/17] Missed one --- src/fastmcp/server/server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 97f14a7c9..25db16cd1 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1136,9 +1136,7 @@ class FastMCP(Generic[LifespanResultT]): if not self._should_enable_component(resource): # Parent filter blocks this resource, continue searching continue - result = list[ReadResourceContents]( - await mounted.server._read_resource_middleware(key) - ) + result = list(await mounted.server._read_resource_middleware(key)) return result except NotFoundError: continue From 55b60bcebabd27c396e64451ef5d9ec245ab5bf9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 12:29:20 -0400 Subject: [PATCH 09/17] Bump astral-sh/setup-uv from 6 to 7 (#2080) --- .github/workflows/auto-close-duplicates.yml | 2 +- .github/workflows/martian-issue-triage.yml | 2 +- .github/workflows/marvin.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/run-static.yml | 2 +- .github/workflows/run-tests.yml | 4 ++-- .github/workflows/update-config-schema.yml | 2 +- .github/workflows/update-sdk-docs.yml | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) 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" From 7f4119dcf6237c45d19794d039608da20ce830cb Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Tue, 14 Oct 2025 14:24:51 -0400 Subject: [PATCH 10/17] Fix asyncio error when running FastMCP 1.x servers (#2084) Co-authored-by: Claude --- src/fastmcp/cli/run.py | 21 +++-- tests/cli/test_run.py | 204 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 8 deletions(-) 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/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.""" From 1ec2bd7d925887578e890cd89b595e42a4fa12ef Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Oct 2025 13:38:26 -0500 Subject: [PATCH 11/17] Also push client messages (info/warn/debug) to server debug log (#2063) Co-authored-by: William Easton Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- docs/servers/logging.mdx | 29 ++++++--- src/fastmcp/client/logging.py | 26 ++++---- src/fastmcp/server/context.py | 104 ++++++++++++++++++++++++++----- src/fastmcp/utilities/logging.py | 89 +++++++++++++++++++++++++- tests/client/test_logs.py | 13 ++-- tests/server/test_context.py | 5 +- 6 files changed, 223 insertions(+), 43 deletions(-) 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/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/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/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/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/server/test_context.py b/tests/server/test_context.py index 97c2c51b7..b0a7fc4c4 100644 --- a/tests/server/test_context.py +++ b/tests/server/test_context.py @@ -5,7 +5,10 @@ import pytest from mcp.types import ModelPreferences from starlette.requests import Request -from fastmcp.server.context import Context, _parse_model_preferences +from fastmcp.server.context import ( + Context, + _parse_model_preferences, +) from fastmcp.server.server import FastMCP From e036cba383a199a11974752155ea5b8c6b3a0cf5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:49:30 -0400 Subject: [PATCH 12/17] Add Pydantic-compatible input validation (#2073) --- docs/servers/server.mdx | 11 +- docs/servers/tools.mdx | 567 +++++++---------------- src/fastmcp/server/server.py | 10 +- src/fastmcp/settings.py | 24 +- src/fastmcp/tools/tool_manager.py | 4 + tests/client/test_client.py | 3 +- tests/server/test_input_validation.py | 353 ++++++++++++++ tests/server/test_server_interactions.py | 66 ++- tests/tools/test_tool_manager.py | 4 +- 9 files changed, 581 insertions(+), 461 deletions(-) create mode 100644 tests/server/test_input_validation.py 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/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 25db16cd1..8d7dbc05c 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -150,6 +150,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 --- @@ -219,6 +220,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 [] @@ -391,7 +397,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) 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/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/server/test_input_validation.py b/tests/server/test_input_validation.py new file mode 100644 index 000000000..e50f2ee7e --- /dev/null +++ b/tests/server/test_input_validation.py @@ -0,0 +1,353 @@ +""" +Tests for input validation behavior with strict_input_validation setting. + +This module tests the difference between strict JSON schema validation (when +strict_input_validation=True) and Pydantic-based coercion (when +strict_input_validation=False, the default). +""" + +import json + +import pytest +from pydantic import BaseModel + +from fastmcp import Client, FastMCP + + +class UserProfile(BaseModel): + """A test model for validating Pydantic model arguments.""" + + name: str + age: int + email: str + + +class TestStringToIntegerCoercion: + """Test string-to-integer coercion behavior.""" + + async def test_string_integer_with_strict_validation(self): + """With strict validation, string integers should raise an error.""" + mcp = FastMCP("TestServer", strict_input_validation=True) + + @mcp.tool + def add_numbers(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + async with Client(mcp) as client: + # String integers should fail with strict validation + with pytest.raises(Exception) as exc_info: + await client.call_tool("add_numbers", {"a": "10", "b": "20"}) + + # Verify it's a validation error + error_msg = str(exc_info.value).lower() + assert ( + "validation" in error_msg + or "invalid" in error_msg + or "type" in error_msg + ) + + async def test_string_integer_without_strict_validation(self): + """Without strict validation, string integers should be coerced.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def add_numbers(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + async with Client(mcp) as client: + # String integers should be coerced to integers + result = await client.call_tool("add_numbers", {"a": "10", "b": "20"}) + assert result.content[0].text == "30" # type: ignore[attr-defined] + + async def test_default_is_not_strict(self): + """By default, strict_input_validation should be False.""" + mcp = FastMCP("TestServer") + + @mcp.tool + def multiply(x: int, y: int) -> int: + """Multiply two numbers.""" + return x * y + + async with Client(mcp) as client: + # Should work with string integers by default + result = await client.call_tool("multiply", {"x": "5", "y": "3"}) + assert result.content[0].text == "15" # type: ignore[attr-defined] + + async def test_string_float_coercion(self): + """Test that string floats are also coerced.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def calculate_area(length: float, width: float) -> float: + """Calculate rectangle area.""" + return length * width + + async with Client(mcp) as client: + result = await client.call_tool( + "calculate_area", {"length": "10.5", "width": "20.0"} + ) + assert result.content[0].text == "210.0" # type: ignore[attr-defined] + + async def test_invalid_coercion_still_fails(self): + """Even without strict validation, truly invalid inputs should fail.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def square(n: int) -> int: + """Square a number.""" + return n * n + + async with Client(mcp) as client: + # Non-numeric strings should still fail + with pytest.raises(Exception): + await client.call_tool("square", {"n": "not-a-number"}) + + +class TestPydanticModelArguments: + """Test validation of Pydantic model arguments.""" + + async def test_pydantic_model_with_dict_no_strict(self): + """Pydantic models should accept dict arguments without strict validation.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def create_user(profile: UserProfile) -> str: + """Create a user from a profile.""" + return f"Created user {profile.name}, age {profile.age}" + + async with Client(mcp) as client: + result = await client.call_tool( + "create_user", + {"profile": {"name": "Alice", "age": 30, "email": "alice@example.com"}}, + ) + assert "Alice" in result.content[0].text # type: ignore[attr-defined] + assert "30" in result.content[0].text # type: ignore[attr-defined] + + async def test_pydantic_model_with_stringified_json_no_strict(self): + """Test if stringified JSON is accepted for Pydantic models without strict validation.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def create_user(profile: UserProfile) -> str: + """Create a user from a profile.""" + return f"Created user {profile.name}, age {profile.age}" + + async with Client(mcp) as client: + # Some LLM clients send stringified JSON instead of actual JSON + stringified = json.dumps( + {"name": "Bob", "age": 25, "email": "bob@example.com"} + ) + + # This test verifies whether we handle stringified JSON + try: + result = await client.call_tool("create_user", {"profile": stringified}) + # If this succeeds, we're handling stringified JSON + assert "Bob" in result.content[0].text # type: ignore[attr-defined] + stringified_json_works = True + except Exception as e: + # If this fails, we're not handling stringified JSON + stringified_json_works = False + error_msg = str(e) + + # Document the behavior - we want to know if this works or not + if stringified_json_works: + # This is the desired behavior + pass + else: + # This means stringified JSON doesn't work - document it + assert ( + "validation" in error_msg.lower() or "invalid" in error_msg.lower() + ) + + async def test_pydantic_model_with_coercion(self): + """Pydantic models should benefit from coercion without strict validation.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def create_user(profile: UserProfile) -> str: + """Create a user from a profile.""" + return f"Created user {profile.name}, age {profile.age}" + + async with Client(mcp) as client: + # Age as string should be coerced + result = await client.call_tool( + "create_user", + { + "profile": { + "name": "Charlie", + "age": "35", # String instead of int + "email": "charlie@example.com", + } + }, + ) + assert "Charlie" in result.content[0].text # type: ignore[attr-defined] + assert "35" in result.content[0].text # type: ignore[attr-defined] + + async def test_pydantic_model_strict_validation(self): + """With strict validation, Pydantic models should enforce exact types.""" + mcp = FastMCP("TestServer", strict_input_validation=True) + + @mcp.tool + def create_user(profile: UserProfile) -> str: + """Create a user from a profile.""" + return f"Created user {profile.name}, age {profile.age}" + + async with Client(mcp) as client: + # Age as string should fail with strict validation + with pytest.raises(Exception): + await client.call_tool( + "create_user", + { + "profile": { + "name": "Dave", + "age": "40", # String instead of int + "email": "dave@example.com", + } + }, + ) + + +class TestValidationErrorMessages: + """Test the quality of validation error messages.""" + + async def test_error_message_quality_strict(self): + """Capture error message with strict validation.""" + mcp = FastMCP("TestServer", strict_input_validation=True) + + @mcp.tool + def process_data(count: int, name: str) -> str: + """Process some data.""" + return f"Processed {count} items for {name}" + + async with Client(mcp) as client: + with pytest.raises(Exception) as exc_info: + await client.call_tool( + "process_data", {"count": "not-a-number", "name": "test"} + ) + + error_msg = str(exc_info.value) + # Strict validation error message + # Should mention validation or type error + assert ( + "validation" in error_msg.lower() + or "invalid" in error_msg.lower() + or "type" in error_msg.lower() + ) + + async def test_error_message_quality_pydantic(self): + """Capture error message with Pydantic validation.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def process_data(count: int, name: str) -> str: + """Process some data.""" + return f"Processed {count} items for {name}" + + async with Client(mcp) as client: + with pytest.raises(Exception) as exc_info: + await client.call_tool( + "process_data", {"count": "not-a-number", "name": "test"} + ) + + error_msg = str(exc_info.value) + # Pydantic validation error message + # Should be more detailed and mention validation + assert "validation" in error_msg.lower() or "invalid" in error_msg.lower() + + async def test_missing_required_field_error(self): + """Test error message for missing required fields.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def greet(name: str, age: int) -> str: + """Greet a person.""" + return f"Hello {name}, you are {age} years old" + + async with Client(mcp) as client: + with pytest.raises(Exception) as exc_info: + # Missing 'age' parameter + await client.call_tool("greet", {"name": "Alice"}) + + error_msg = str(exc_info.value) + # Should mention the missing field + assert "age" in error_msg.lower() or "required" in error_msg.lower() + + +class TestEdgeCases: + """Test edge cases and boundary conditions.""" + + async def test_optional_parameters_with_coercion(self): + """Optional parameters should work with coercion.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def format_message(text: str, repeat: int = 1) -> str: + """Format a message with optional repetition.""" + return text * repeat + + async with Client(mcp) as client: + # String for optional int parameter + result = await client.call_tool( + "format_message", {"text": "hi", "repeat": "3"} + ) + assert result.content[0].text == "hihihi" # type: ignore[attr-defined] + + async def test_none_values(self): + """Test handling of None values.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def process_optional(value: int | None) -> str: + """Process an optional value.""" + return f"Value: {value}" + + async with Client(mcp) as client: + result = await client.call_tool("process_optional", {"value": None}) + assert "None" in result.content[0].text # type: ignore[attr-defined] + + async def test_empty_string_to_int(self): + """Empty strings should fail conversion to int.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def square(n: int) -> int: + """Square a number.""" + return n * n + + async with Client(mcp) as client: + with pytest.raises(Exception): + await client.call_tool("square", {"n": ""}) + + async def test_boolean_coercion(self): + """Test boolean value coercion.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def toggle(enabled: bool) -> str: + """Toggle a feature.""" + return f"Feature is {'enabled' if enabled else 'disabled'}" + + async with Client(mcp) as client: + # String "true" should be coerced to boolean + result = await client.call_tool("toggle", {"enabled": "true"}) + assert "enabled" in result.content[0].text.lower() # type: ignore[attr-defined] + + # String "false" should be coerced to boolean + result = await client.call_tool("toggle", {"enabled": "false"}) + assert "disabled" in result.content[0].text.lower() # type: ignore[attr-defined] + + async def test_list_of_integers_with_string_elements(self): + """Test lists containing string representations of integers.""" + mcp = FastMCP("TestServer", strict_input_validation=False) + + @mcp.tool + def sum_numbers(numbers: list[int]) -> int: + """Sum a list of numbers.""" + return sum(numbers) + + async with Client(mcp) as client: + # List with string integers + result = await client.call_tool("sum_numbers", {"numbers": ["1", "2", "3"]}) + assert result.content[0].text == "6" # type: ignore[attr-defined] diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 030cd4223..76f1dffb2 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -604,12 +604,12 @@ class TestToolParameters: async with Client(mcp) as client: with pytest.raises( ToolError, - match="Input validation error: 'not an int' is not of type 'integer'", + match="Input should be a valid integer", ): await client.call_tool("my_tool", {"x": "not an int"}) async def test_tool_int_coercion(self): - """Test that invalid int input raises validation error.""" + """Test that string ints are coerced by default.""" mcp = FastMCP() @mcp.tool @@ -617,15 +617,12 @@ class TestToolParameters: return x + 1 async with Client(mcp) as client: - # String input should raise validation error (no coercion) - with pytest.raises( - ToolError, - match="Input validation error: '42' is not of type 'integer'", - ): - await client.call_tool("add_one", {"x": "42"}) + # String input should be coerced with default settings + result = await client.call_tool("add_one", {"x": "42"}) + assert result.data == 43 async def test_tool_bool_coercion(self): - """Test that invalid bool input raises validation error.""" + """Test that string bools are coerced by default.""" mcp = FastMCP() @mcp.tool @@ -633,18 +630,12 @@ class TestToolParameters: return not flag async with Client(mcp) as client: - # String input should raise validation error (no coercion) - with pytest.raises( - ToolError, - match="Input validation error: 'true' is not of type 'boolean'", - ): - await client.call_tool("toggle", {"flag": "true"}) + # String input should be coerced with default settings + result = await client.call_tool("toggle", {"flag": "true"}) + assert result.data is False - with pytest.raises( - ToolError, - match="Input validation error: 'false' is not of type 'boolean'", - ): - await client.call_tool("toggle", {"flag": "false"}) + result = await client.call_tool("toggle", {"flag": "false"}) + assert result.data is True async def test_annotated_field_validation(self): mcp = FastMCP() @@ -656,7 +647,7 @@ class TestToolParameters: async with Client(mcp) as client: with pytest.raises( ToolError, - match="Input validation error: 0 is less than the minimum of 1", + match="Input should be greater than or equal to 1", ): await client.call_tool("analyze", {"x": 0}) @@ -670,7 +661,7 @@ class TestToolParameters: async with Client(mcp) as client: with pytest.raises( ToolError, - match="Input validation error: 0 is less than the minimum of 1", + match="Input should be greater than or equal to 1", ): await client.call_tool("analyze", {"x": 0}) @@ -682,9 +673,7 @@ class TestToolParameters: pass async with Client(mcp) as client: - with pytest.raises( - ToolError, match="Input validation error: 'x' is a required property" - ): + with pytest.raises(ToolError, match="Missing required argument"): await client.call_tool("analyze", {}) async def test_literal_type_validation_error(self): @@ -697,7 +686,7 @@ class TestToolParameters: async with Client(mcp) as client: with pytest.raises( ToolError, - match=r"Input validation error: 'c' is not one of \['a', 'b'\]", + match="Input should be 'a' or 'b'", ): await client.call_tool("analyze", {"x": "c"}) @@ -727,7 +716,7 @@ class TestToolParameters: async with Client(mcp) as client: with pytest.raises( ToolError, - match=r"Input validation error: 'some-color' is not one of \['red', 'green', 'blue'\]", + match="Input should be 'red', 'green' or 'blue'", ): await client.call_tool("analyze", {"x": "some-color"}) @@ -763,7 +752,7 @@ class TestToolParameters: with pytest.raises( ToolError, - match="Input validation error: 'not a number' is not valid under any of the given schemas", + match="Input should be a valid", ): await client.call_tool("analyze", {"x": "not a number"}) @@ -790,9 +779,7 @@ class TestToolParameters: return str(path) async with Client(mcp) as client: - with pytest.raises( - ToolError, match="Input validation error: 1 is not of type 'string'" - ): + with pytest.raises(ToolError, match="Input is not a valid path"): await client.call_tool("send_path", {"path": 1}) async def test_uuid_type(self): @@ -817,7 +804,7 @@ class TestToolParameters: return str(x) async with Client(mcp) as client: - with pytest.raises(ToolError, match="Error calling tool 'send_uuid'"): + with pytest.raises(ToolError, match="Input should be a valid UUID"): await client.call_tool("send_uuid", {"x": "not a uuid"}) async def test_datetime_type(self): @@ -854,7 +841,7 @@ class TestToolParameters: return x.isoformat() async with Client(mcp) as client: - with pytest.raises(ToolError, match="Error calling tool 'send_datetime'"): + with pytest.raises(ToolError, match="Input should be a valid datetime"): await client.call_tool("send_datetime", {"x": "not a datetime"}) async def test_date_type(self): @@ -893,7 +880,7 @@ class TestToolParameters: assert result.data == "1 day, 0:00:00" async def test_timedelta_type_parse_int(self): - """Test that invalid timedelta input raises validation error.""" + """Test that int input is coerced to timedelta (seconds).""" mcp = FastMCP() @mcp.tool @@ -901,12 +888,11 @@ class TestToolParameters: return str(x) async with Client(mcp) as client: - # Int input should raise validation error (no conversion) - with pytest.raises( - ToolError, - match="Input validation error: 1000 is not of type 'string'", - ): - await client.call_tool("send_timedelta", {"x": 1000}) + # Int input should be coerced to timedelta (seconds) + result = await client.call_tool("send_timedelta", {"x": 1000}) + assert ( + "0:16:40" in result.data or "16:40" in result.data + ) # 1000 seconds = 16 minutes 40 seconds async def test_annotated_string_description(self): mcp = FastMCP() diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index e39e0a7b8..1ece0dfd2 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -7,7 +7,7 @@ import pydantic_core import pytest from inline_snapshot import snapshot from mcp.types import ImageContent, TextContent -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from fastmcp import Context, FastMCP from fastmcp.exceptions import NotFoundError, ToolError @@ -472,7 +472,7 @@ class TestCallTools: manager = ToolManager() tool = Tool.from_function(add) manager.add_tool(tool) - with pytest.raises(ToolError): + with pytest.raises(ValidationError): await manager.call_tool("add", {"a": 1}) async def test_call_unknown_tool(self): From 5d195878c442b5ef45daa4707e8d49abef2aecee Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Oct 2025 13:49:53 -0500 Subject: [PATCH 13/17] Switch Lifespan to being a Server Lifespan (#2013) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- src/fastmcp/client/transports.py | 45 +++++++---- src/fastmcp/server/http.py | 11 ++- src/fastmcp/server/server.py | 116 ++++++++++++++++++--------- tests/client/test_sse.py | 3 +- tests/server/test_mount.py | 7 +- tests/server/test_server_lifespan.py | 68 ++++++++++++++++ 6 files changed, 189 insertions(+), 61 deletions(-) create mode 100644 tests/server/test_server_lifespan.py diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 3664c21d3..2cadd0631 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -852,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/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/server.py b/src/fastmcp/server/server.py index 8d7dbc05c..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, @@ -189,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 @@ -340,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, @@ -1888,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, @@ -1967,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, @@ -2236,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) @@ -2370,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/tests/client/test_sse.py b/tests/client/test_sse.py index f2fe86605..818f233fd 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -92,7 +92,8 @@ async def test_http_headers(sse_server: str): def run_nested_server(host: str, port: int) -> None: - app = fastmcp_server().sse_app(path="/mcp/sse/", message_path="/mcp/messages") + fastmcp = fastmcp_server() + app = fastmcp.sse_app(path="/mcp/sse/", message_path="/mcp/messages") mount = Starlette(routes=[Mount("/nest-inner", app=app)]) mount2 = Starlette(routes=[Mount("/nest-outer", app=mount)]) server = uvicorn.Server( diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index e3fd3abd1..d02e86b6c 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -888,15 +888,18 @@ class TestAsProxyKwarg: assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy) async def test_as_proxy_defaults_true_if_lifespan(self): + """Test that as_proxy defaults to True when server_lifespan is provided.""" + @asynccontextmanager - async def lifespan(mcp: FastMCP): + async def server_lifespan(mcp: FastMCP): yield mcp = FastMCP("Main") - sub = FastMCP("Sub", lifespan=lifespan) + sub = FastMCP("Sub", lifespan=server_lifespan) mcp.mount(sub, "sub") + # Should auto-proxy because lifespan is set assert mcp._mounted_servers[0].server is not sub assert isinstance(mcp._mounted_servers[0].server, FastMCPProxy) diff --git a/tests/server/test_server_lifespan.py b/tests/server/test_server_lifespan.py new file mode 100644 index 000000000..a4373a4a7 --- /dev/null +++ b/tests/server/test_server_lifespan.py @@ -0,0 +1,68 @@ +"""Tests for server_lifespan and session_lifespan behavior.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from fastmcp import Client, FastMCP +from fastmcp.server.context import Context + + +class TestServerLifespan: + """Test server_lifespan functionality.""" + + async def test_server_lifespan_basic(self): + """Test that server_lifespan is entered once and persists across sessions.""" + lifespan_events: list[str] = [] + + @asynccontextmanager + async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict[str, Any]]: + _ = lifespan_events.append("enter") + yield {"initialized": True} + _ = lifespan_events.append("exit") + + mcp = FastMCP("TestServer", lifespan=server_lifespan) + + @mcp.tool + def get_value() -> str: + return "test" + + # Server lifespan should be entered when run_async starts + assert lifespan_events == [] + + # Connect first client session + async with Client(mcp) as client1: + result1 = await client1.call_tool("get_value", {}) + assert result1.data == "test" + # Server lifespan should have been entered once + assert lifespan_events == ["enter"] + + # Connect second client session while first is still active + async with Client(mcp) as client2: + result2 = await client2.call_tool("get_value", {}) + assert result2.data == "test" + # Server lifespan should still only have been entered once + assert lifespan_events == ["enter"] + + # Because we're using a fastmcptransport, the server lifespan should be exited + # when the client session closes + assert lifespan_events == ["enter", "exit"] + + async def test_server_lifespan_context_available(self): + """Test that server_lifespan context is available to tools.""" + + @asynccontextmanager + async def server_lifespan(mcp: FastMCP) -> AsyncIterator[dict]: + yield {"db_connection": "mock_db"} + + mcp = FastMCP("TestServer", lifespan=server_lifespan) + + @mcp.tool + def get_db_info(ctx: Context) -> str: + # Access the server lifespan context + lifespan_context = ctx.request_context.lifespan_context + return lifespan_context.get("db_connection", "no_db") + + async with Client(mcp) as client: + result = await client.call_tool("get_db_info", {}) + assert result.data == "mock_db" From cc6df567daa0bbd564b87070dd9a37529730ef3f Mon Sep 17 00:00:00 2001 From: William Easton Date: Tue, 14 Oct 2025 13:54:47 -0500 Subject: [PATCH 14/17] bump kv to 0.2.2 --- pyproject.toml | 2 +- uv.lock | 25 ++++++++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 00e4b7d09..d81a55dfe 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/uv.lock b/uv.lock index 72c90e253..37903520c 100644 --- a/uv.lock +++ b/uv.lock @@ -69,6 +69,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "beartype" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/96/43ed27f27127155f24f5cf85df0c27fd2ac2ab67d94cecc8f76933f91679/beartype-0.22.2.tar.gz", hash = "sha256:ff3a7df26af8d15fa87f97934f0f6d41bbdadca971c410819104998dd26013d2", size = 1574491, upload-time = "2025-10-04T06:37:56.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/2a/a4773109619010192e72f48e95165b14790413a51f513c879c8d63f67e17/beartype-0.22.2-py3-none-any.whl", hash = "sha256:12077afe3528eba5c5b801f816712f7ff06f6da5509994c79561e29b48bcedb8", size = 1317280, upload-time = "2025-10-04T06:37:53.99Z" }, +] + [[package]] name = "cachetools" version = "6.2.0" @@ -594,7 +603,7 @@ requires-dist = [ { name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" }, { name = "openapi-core", specifier = ">=0.19.5" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, - { name = "py-key-value-aio", extras = ["disk", "memory"], specifier = ">=0.2.1" }, + { name = "py-key-value-aio", extras = ["disk", "memory"], specifier = ">=0.2.2" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, @@ -1310,14 +1319,15 @@ wheels = [ [[package]] name = "py-key-value-aio" -version = "0.2.1" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "beartype" }, { name = "py-key-value-shared" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/bf/7237a1d41b4afc33a8c0f71c991d95a6bb6719cd5ccab8d1628b72fbe03c/py_key_value_aio-0.2.1.tar.gz", hash = "sha256:79c8c835451b61d4abd863c65d33870612f3a80dc312120b2d1445269764d625", size = 19440, upload-time = "2025-10-09T03:26:28.357Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/d0/931ea2ca54eba5b1cf53e6fa29e371a58e53ce327cb84ae0317d1269400e/py_key_value_aio-0.2.2.tar.gz", hash = "sha256:e8e4ea8a9c5c5e7b1c79e019e47cd8595d0d4c2bc5be977e357de734f920c96f", size = 20877, upload-time = "2025-10-14T18:10:09.672Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/82/41b5574270fbed7171d34a9b7c9b1b18fd86c31421e45dc935ba354eb42f/py_key_value_aio-0.2.1-py3-none-any.whl", hash = "sha256:5f0bc1bb3f886578a88ed2b61858658142db35c59dd3ccd9ec727184c540288a", size = 41564, upload-time = "2025-10-09T03:26:26.174Z" }, + { url = "https://files.pythonhosted.org/packages/22/eb/bb0b1cb92defee373635fc723af11e093c54b5ed614d825735c03decfc47/py_key_value_aio-0.2.2-py3-none-any.whl", hash = "sha256:59a2858807adc3bfdf24ac6e65c091ef914a871ea89f1293ccd550d48020d1a7", size = 44077, upload-time = "2025-10-14T18:10:08.874Z" }, ] [package.optional-dependencies] @@ -1331,14 +1341,15 @@ memory = [ [[package]] name = "py-key-value-shared" -version = "0.2.0" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "beartype" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/09/7c76aa82e5e41c6ad5e0e43bcc2072b48d84e03439dbb25b3e184773b553/py_key_value_shared-0.2.0.tar.gz", hash = "sha256:ee6d9a9101b54f228876c61b2f2f83a951c9c52233d8271599532c069fa26052", size = 6285, upload-time = "2025-09-29T02:27:46.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/35/c837273b0404ea285da8a881e4dbd47d096b866bcfacaf234ca4bd529c4c/py_key_value_shared-0.2.2.tar.gz", hash = "sha256:7e922efb721d6ba0ef23101a1d96a2a30fa2b55c2dade090f26f32f0edb09ff6", size = 7209, upload-time = "2025-10-14T18:10:10.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/f8/6c6cf5abcb78d103006ea1bec6137c9859611ffc50093684b5130c5642c1/py_key_value_shared-0.2.0-py3-none-any.whl", hash = "sha256:84cb4f6b6bed97a32feebc512ce1e333097ce5768c7198abcd7d4bd3c5f1de06", size = 10437, upload-time = "2025-09-29T02:27:45.281Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9b/c56cc06403305c3cd8c6deb0eae91f59bbe442f0e1b195ebdb822718ea80/py_key_value_shared-0.2.2-py3-none-any.whl", hash = "sha256:5073cce73450471990e3fa01d2e2c158588a47e6324feaf067a29f0b189a7194", size = 12035, upload-time = "2025-10-14T18:10:09.247Z" }, ] [[package]] From 541822f07ff28b196bde09d3818fea0579becd86 Mon Sep 17 00:00:00 2001 From: Roee Hershko <113914991+roee-hersh@users.noreply.github.com> Date: Tue, 14 Oct 2025 22:00:28 +0300 Subject: [PATCH 15/17] add azp claim (#1945) Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- src/fastmcp/server/auth/providers/jwt.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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") From 2a20f54617a37213ed83894a8c2f0ac38a2e83a3 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:30:21 -0400 Subject: [PATCH 16/17] Escape all HTML to prevent XSS attack (#2090) --- src/fastmcp/client/oauth_callback.py | 4 +- src/fastmcp/utilities/ui.py | 9 +- tests/client/test_oauth_callback_xss.py | 159 ++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 tests/client/test_oauth_callback_xss.py 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/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/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 "