Validate version metadata to reject non-scalar types (#3437)

* Validate version metadata to reject non-scalar types (#3422)

🤖 Generated with Claude Code

* Reject bool values in version coercion
This commit is contained in:
Jeremiah Lowin 2026-03-07 11:40:36 -05:00 committed by GitHub
commit 9ec4e7ae1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 68 additions and 5 deletions

View file

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

View file

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

View file

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