From 9ec4e7ae1b1e64c79949c0a9d8cf1b634e6151d5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:40:36 -0500 Subject: [PATCH] Validate version metadata to reject non-scalar types (#3437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Validate version metadata to reject non-scalar types (#3422) 🤖 Generated with Claude Code * Reject bool values in version coercion --- src/fastmcp/server/server.py | 6 +-- src/fastmcp/utilities/components.py | 11 ++++- tests/server/versioning/test_versioning.py | 56 ++++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 0dc47f143..fb652448b 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -79,7 +79,7 @@ from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool import Tool, ToolResult from fastmcp.tools.tool_transform import ToolTransformConfig -from fastmcp.utilities.components import FastMCPComponent +from fastmcp.utilities.components import FastMCPComponent, _coerce_version from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import FastMCPBaseModel, NotSet, NotSetT from fastmcp.utilities.versions import ( @@ -221,7 +221,7 @@ class FastMCP( name: str | None = None, instructions: str | None = None, *, - version: str | None = None, + version: str | int | float | None = None, website_url: str | None = None, icons: list[mcp.types.Icon] | None = None, auth: AuthProvider | None = None, @@ -308,7 +308,7 @@ class FastMCP( ]( fastmcp=self, name=name or self.generate_name(), - version=version or fastmcp.__version__, + version=_coerce_version(version) or fastmcp.__version__, instructions=instructions, website_url=website_url, icons=icons, diff --git a/src/fastmcp/utilities/components.py b/src/fastmcp/utilities/components.py index 168599807..30ad17f4d 100644 --- a/src/fastmcp/utilities/components.py +++ b/src/fastmcp/utilities/components.py @@ -43,13 +43,20 @@ def _convert_set_default_none(maybe_set: set[T] | Sequence[T] | None) -> set[T]: return set(maybe_set) -def _coerce_version(v: str | int | None) -> str | None: - """Coerce version to string, accepting int or str. +def _coerce_version(v: str | int | float | None) -> str | None: + """Coerce version to string, accepting int, float, or str. + Raises TypeError for non-scalar types (list, dict, set, etc.). Raises ValueError if version contains '@' (used as key delimiter). """ if v is None: return None + if isinstance(v, bool): + raise TypeError(f"Version must be a string, int, or float, got bool: {v!r}") + if not isinstance(v, (str, int, float)): + raise TypeError( + f"Version must be a string, int, or float, got {type(v).__name__}: {v!r}" + ) version = str(v) if "@" in version: raise ValueError( diff --git a/tests/server/versioning/test_versioning.py b/tests/server/versioning/test_versioning.py index cf910bc4a..f2866df06 100644 --- a/tests/server/versioning/test_versioning.py +++ b/tests/server/versioning/test_versioning.py @@ -3,9 +3,13 @@ from __future__ import annotations +from typing import cast + +import pytest from mcp.types import TextContent from fastmcp import FastMCP +from fastmcp.tools import Tool from fastmcp.utilities.versions import ( VersionKey, compare_versions, @@ -256,3 +260,55 @@ class TestComponentVersioning: prompt = await mcp.get_prompt("greet") assert prompt is not None assert prompt.version == "2.0" + + +class TestVersionValidation: + """Tests for version type validation in components and server.""" + + async def test_fastmcp_version_int_coerced(self): + """FastMCP(version=42) should coerce to string '42'.""" + mcp = FastMCP(version=42) + assert mcp._mcp_server.version == "42" + + async def test_fastmcp_version_float_coerced(self): + """FastMCP(version=1.5) should coerce to string.""" + mcp = FastMCP(version=1.5) + assert mcp._mcp_server.version == "1.5" + + async def test_tool_version_list_rejected(self): + """Tool with version=[1, 2] should raise TypeError.""" + with pytest.raises(TypeError, match="Version must be a string"): + Tool( + name="t", + version=cast(str, [1, 2]), + parameters={"type": "object"}, + ) + + async def test_tool_version_dict_rejected(self): + """Tool with version={'major': 1} should raise TypeError.""" + with pytest.raises(TypeError, match="Version must be a string"): + Tool( + name="t", + version=cast(str, {"major": 1}), + parameters={"type": "object"}, + ) + + async def test_fastmcp_version_list_rejected(self): + """FastMCP(version=[1, 2]) should raise TypeError.""" + with pytest.raises(TypeError, match="Version must be a string"): + FastMCP(version=cast(str, [1, 2])) + + async def test_fastmcp_version_dict_rejected(self): + """FastMCP(version={'v': 1}) should raise TypeError.""" + with pytest.raises(TypeError, match="Version must be a string"): + FastMCP(version=cast(str, {"v": 1})) + + async def test_fastmcp_version_true_rejected(self): + """FastMCP(version=True) should raise TypeError, not coerce to 'True'.""" + with pytest.raises(TypeError, match="got bool"): + FastMCP(version=cast(str, True)) + + async def test_fastmcp_version_false_rejected(self): + """FastMCP(version=False) should raise TypeError, not coerce to 'False'.""" + with pytest.raises(TypeError, match="got bool"): + FastMCP(version=cast(str, False))