From bf1a55240f49d451d7f38c8bf3c531bc39261405 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:08:25 -0500 Subject: [PATCH 1/4] Update classes to inherit from FastMCPBaseModel instead of BaseModel Replace pydantic.BaseModel with FastMCPBaseModel for FastMCP classes to ensure extra='forbid' by default, preventing silent acceptance of incorrect initialization kwargs. Updated classes: - Deployment, MCPServerConfig (config classes) - ToolResult (tool classes) - SamplingTool (sampling classes, preserves arbitrary_types_allowed) - CachableResourceContent, CachableResourceResult, CachableToolResult, ResponseCachingStatistics (caching classes) - EventEntry, StreamEventList (event store classes) - ResourceContent, ResourceResult (resource classes) --- src/fastmcp/server/event_store.py | 6 +++--- src/fastmcp/server/middleware/caching.py | 11 ++++++----- src/fastmcp/server/sampling/sampling_tool.py | 5 +++-- src/fastmcp/tools/tool.py | 11 ++++++----- .../mcp_server_config/v1/mcp_server_config.py | 7 ++++--- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/fastmcp/server/event_store.py b/src/fastmcp/server/event_store.py index 79f192000b..9304a931e5 100644 --- a/src/fastmcp/server/event_store.py +++ b/src/fastmcp/server/event_store.py @@ -16,14 +16,14 @@ from mcp.server.streamable_http import EventCallback, EventId, EventMessage, StreamId from mcp.server.streamable_http import EventStore as SDKEventStore from mcp.types import JSONRPCMessage -from pydantic import BaseModel from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import FastMCPBaseModel logger = get_logger(__name__) -class EventEntry(BaseModel): +class EventEntry(FastMCPBaseModel): """Stored event entry.""" event_id: str @@ -31,7 +31,7 @@ class EventEntry(BaseModel): message: dict | None # JSONRPCMessage serialized to dict -class StreamEventList(BaseModel): +class StreamEventList(FastMCPBaseModel): """List of event IDs for a stream.""" event_ids: list[str] diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index a068e0e216..cba14d8e9e 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -14,7 +14,7 @@ from key_value.aio.wrappers.statistics.wrapper import ( KVStoreCollectionStatistics, ) -from pydantic import BaseModel, Field +from pydantic import Field from typing_extensions import NotRequired, Self, override from fastmcp.prompts.prompt import Prompt, PromptResult @@ -22,6 +22,7 @@ from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.tool import Tool, ToolResult from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import FastMCPBaseModel logger: Logger = get_logger(name=__name__) @@ -34,7 +35,7 @@ GLOBAL_KEY = "__global__" -class CachableResourceContent(BaseModel): +class CachableResourceContent(FastMCPBaseModel): """A wrapper for ResourceContent that can be cached.""" content: str | bytes @@ -42,7 +43,7 @@ class CachableResourceContent(BaseModel): meta: dict[str, Any] | None = None -class CachableResourceResult(BaseModel): +class CachableResourceResult(FastMCPBaseModel): """A wrapper for ResourceResult that can be cached.""" contents: list[CachableResourceContent] @@ -75,7 +76,7 @@ def unwrap(self) -> ResourceResult: ) -class CachableToolResult(BaseModel): +class CachableToolResult(FastMCPBaseModel): content: list[mcp.types.ContentBlock] structured_content: dict[str, Any] | None meta: dict[str, Any] | None @@ -130,7 +131,7 @@ class GetPromptSettings(SharedMethodSettings): """Configuration options for Prompt-related caching.""" -class ResponseCachingStatistics(BaseModel): +class ResponseCachingStatistics(FastMCPBaseModel): list_tools: KVStoreCollectionStatistics | None = Field(default=None) list_resources: KVStoreCollectionStatistics | None = Field(default=None) list_prompts: KVStoreCollectionStatistics | None = Field(default=None) diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py index 8bab07389f..3de4668889 100644 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ b/src/fastmcp/server/sampling/sampling_tool.py @@ -7,12 +7,13 @@ from typing import Any from mcp.types import Tool as SDKTool -from pydantic import BaseModel, ConfigDict +from pydantic import ConfigDict from fastmcp.tools.tool import ParsedFunction +from fastmcp.utilities.types import FastMCPBaseModel -class SamplingTool(BaseModel): +class SamplingTool(FastMCPBaseModel): """A tool that can be used during LLM sampling. SamplingTools bundle a tool's schema (name, description, parameters) with diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index b4ea503c7d..502b3916a1 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -26,7 +26,7 @@ ToolExecution, ) from mcp.types import Tool as MCPTool -from pydantic import BaseModel, Field, PydanticSchemaGenerationError, model_validator +from pydantic import Field, PydanticSchemaGenerationError, model_validator from typing_extensions import TypeVar import fastmcp @@ -37,6 +37,7 @@ from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( Audio, + FastMCPBaseModel, File, Image, NotSet, @@ -75,7 +76,7 @@ def default_serializer(data: Any) -> str: return pydantic_core.to_json(data, fallback=str).decode() -class ToolResult(BaseModel): +class ToolResult(FastMCPBaseModel): content: list[ContentBlock] = Field( description="List of content blocks for the tool result" ) @@ -117,9 +118,9 @@ def __init__( ) super().__init__( - content=converted_content, - structured_content=structured_content, - meta=meta, + content=converted_content, # type: ignore[unknown-argument] + structured_content=structured_content, # type: ignore[unknown-argument] + meta=meta, # type: ignore[unknown-argument] ) def to_mcp_result( diff --git a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py index 66a170083c..e054962b1a 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py @@ -13,12 +13,13 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast, overload -from pydantic import BaseModel, Field, field_validator +from pydantic import Field, field_validator from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.sources.base import Source from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource +from fastmcp.utilities.types import FastMCPBaseModel logger = get_logger("cli.config") @@ -33,7 +34,7 @@ EnvironmentType: TypeAlias = UVEnvironment -class Deployment(BaseModel): +class Deployment(FastMCPBaseModel): """Configuration for server deployment and runtime settings.""" transport: Literal["stdio", "http", "sse", "streamable-http"] | None = Field( @@ -131,7 +132,7 @@ def replace_var(match: re.Match) -> str: return re.sub(r"\$\{([^}]+)\}", replace_var, value) -class MCPServerConfig(BaseModel): +class MCPServerConfig(FastMCPBaseModel): """Configuration for a FastMCP server. This configuration file allows you to specify all settings needed to run From 369f305441e6e32ff3c558e4550cc5afb5a1e790 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:24:43 -0500 Subject: [PATCH 2/4] Clean up Result classes: remove **kwargs, fix caching.py - Remove **kwargs from ResourceContent, ResourceResult, Message, PromptResult, ToolResult __init__ methods (custom __init__ already prevents extra kwargs) - Revert these classes to pydantic.BaseModel (FastMCPBaseModel not needed) - Fix CachableMessage and CachablePromptResult to use FastMCPBaseModel (were broken after BaseModel import removal) --- src/fastmcp/prompts/prompt.py | 8 ++------ src/fastmcp/resources/resource.py | 12 +++--------- src/fastmcp/server/middleware/caching.py | 4 ++-- src/fastmcp/tools/tool.py | 9 +++------ 4 files changed, 10 insertions(+), 23 deletions(-) diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 0259e36a93..c50e5be00a 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -68,7 +68,6 @@ def __init__( self, content: Any, role: Literal["user", "assistant"] = "user", - **kwargs: Any, ): """Create Message with automatic serialization. @@ -88,7 +87,7 @@ def __init__( serialized = pydantic_core.to_json(content, fallback=str).decode() normalized_content = TextContent(type="text", text=serialized) - super().__init__(role=role, content=normalized_content, **kwargs) + super().__init__(role=role, content=normalized_content) def to_mcp_prompt_message(self) -> PromptMessage: """Convert to MCP PromptMessage.""" @@ -148,7 +147,6 @@ def __init__( messages: str | list[Message], description: str | None = None, meta: dict[str, Any] | None = None, - **kwargs: Any, ): """Create PromptResult. @@ -158,9 +156,7 @@ def __init__( meta: Optional metadata about the prompt result. """ normalized = self._normalize_messages(messages) - super().__init__( - messages=normalized, description=description, meta=meta, **kwargs - ) + super().__init__(messages=normalized, description=description, meta=meta) @staticmethod def _normalize_messages( diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index c511088577..1a90bdb70e 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -29,9 +29,7 @@ from fastmcp.server.dependencies import without_injected_parameters from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.types import ( - get_fn_name, -) +from fastmcp.utilities.types import get_fn_name class ResourceContent(pydantic.BaseModel): @@ -65,7 +63,6 @@ def __init__( content: Any, mime_type: str | None = None, meta: dict[str, Any] | None = None, - **kwargs: Any, ): """Create ResourceContent with automatic serialization. @@ -88,9 +85,7 @@ def __init__( normalized_content = pydantic_core.to_json(content, fallback=str).decode() mime_type = mime_type or "application/json" - super().__init__( - content=normalized_content, mime_type=mime_type, meta=meta, **kwargs - ) + super().__init__(content=normalized_content, mime_type=mime_type, meta=meta) def to_mcp_resource_contents( self, uri: AnyUrl | str @@ -162,7 +157,6 @@ def __init__( self, contents: str | bytes | list[ResourceContent], meta: dict[str, Any] | None = None, - **kwargs: Any, ): """Create ResourceResult. @@ -171,7 +165,7 @@ def __init__( meta: Optional metadata about the resource result. """ normalized = self._normalize_contents(contents) - super().__init__(contents=normalized, meta=meta, **kwargs) + super().__init__(contents=normalized, meta=meta) @staticmethod def _normalize_contents( diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py index 917ec8a216..d4ac159295 100644 --- a/src/fastmcp/server/middleware/caching.py +++ b/src/fastmcp/server/middleware/caching.py @@ -97,14 +97,14 @@ def unwrap(self) -> ToolResult: ) -class CachableMessage(BaseModel): +class CachableMessage(FastMCPBaseModel): """A wrapper for Message that can be cached.""" role: str content: mcp.types.TextContent | mcp.types.EmbeddedResource -class CachablePromptResult(BaseModel): +class CachablePromptResult(FastMCPBaseModel): """A wrapper for PromptResult that can be cached.""" messages: list[CachableMessage] diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 502b3916a1..ce897bc845 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -26,7 +26,7 @@ ToolExecution, ) from mcp.types import Tool as MCPTool -from pydantic import Field, PydanticSchemaGenerationError, model_validator +from pydantic import BaseModel, Field, PydanticSchemaGenerationError, model_validator from typing_extensions import TypeVar import fastmcp @@ -37,7 +37,6 @@ from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( Audio, - FastMCPBaseModel, File, Image, NotSet, @@ -76,7 +75,7 @@ def default_serializer(data: Any) -> str: return pydantic_core.to_json(data, fallback=str).decode() -class ToolResult(FastMCPBaseModel): +class ToolResult(BaseModel): content: list[ContentBlock] = Field( description="List of content blocks for the tool result" ) @@ -118,9 +117,7 @@ def __init__( ) super().__init__( - content=converted_content, # type: ignore[unknown-argument] - structured_content=structured_content, # type: ignore[unknown-argument] - meta=meta, # type: ignore[unknown-argument] + content=converted_content, structured_content=structured_content, meta=meta ) def to_mcp_result( From 527d53cb76509d0059be7ae9506ec3294f067cce Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:56:05 -0500 Subject: [PATCH 3/4] Revert MCPServerConfig classes to BaseModel These classes use model_dump/reconstruction patterns that conflict with extra='forbid' when aliased fields like schema_ are involved. --- .../utilities/mcp_server_config/v1/mcp_server_config.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py index e054962b1a..66a170083c 100644 --- a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +++ b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py @@ -13,13 +13,12 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast, overload -from pydantic import Field, field_validator +from pydantic import BaseModel, Field, field_validator from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.sources.base import Source from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource -from fastmcp.utilities.types import FastMCPBaseModel logger = get_logger("cli.config") @@ -34,7 +33,7 @@ EnvironmentType: TypeAlias = UVEnvironment -class Deployment(FastMCPBaseModel): +class Deployment(BaseModel): """Configuration for server deployment and runtime settings.""" transport: Literal["stdio", "http", "sse", "streamable-http"] | None = Field( @@ -132,7 +131,7 @@ def replace_var(match: re.Match) -> str: return re.sub(r"\$\{([^}]+)\}", replace_var, value) -class MCPServerConfig(FastMCPBaseModel): +class MCPServerConfig(BaseModel): """Configuration for a FastMCP server. This configuration file allows you to specify all settings needed to run From 40d4867bf80ece15e744449a37218aeb64d73bca Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 25 Dec 2025 22:58:55 -0500 Subject: [PATCH 4/4] Add roundtrip tests for MCPServerConfig --- tests/cli/test_config.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py index 1a5d307eb1..cb4dd00a0e 100644 --- a/tests/cli/test_config.py +++ b/tests/cli/test_config.py @@ -432,3 +432,30 @@ def test_optional_sections(self): if field != "type" ) assert config.deployment.transport == "http" + + +class TestMCPServerConfigRoundtrip: + """Test that MCPServerConfig survives model_dump() -> reconstruct pattern. + + This is used by the CLI to apply overrides immutably. + """ + + def test_roundtrip_preserves_schema(self): + """Ensure schema_ field survives dump/reconstruct cycle.""" + config = MCPServerConfig(source=FileSystemSource(path="server.py")) + config_dict = config.model_dump() + reconstructed = MCPServerConfig(**config_dict) + assert reconstructed.schema_ == config.schema_ + + def test_roundtrip_with_all_fields(self): + """Full config survives dump/reconstruct.""" + config = MCPServerConfig( + source=FileSystemSource(path="server.py", entrypoint="app"), + environment=UVEnvironment(python="3.11"), + deployment=Deployment(transport="http", port=8080), + ) + config_dict = config.model_dump() + reconstructed = MCPServerConfig(**config_dict) + assert reconstructed.source.path == "server.py" + assert reconstructed.environment.python == "3.11" + assert reconstructed.deployment.port == 8080