Skip to content

Commit fe2ec99

Browse files
authored
Validate tool names at registration time (SEP-986) (#2540)
1 parent e1e0553 commit fe2ec99

File tree

2 files changed

+107
-1
lines changed

2 files changed

+107
-1
lines changed

src/fastmcp/tools/tool.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515

1616
import mcp.types
1717
import pydantic_core
18+
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
1819
from mcp.types import CallToolResult, ContentBlock, Icon, TextContent, ToolAnnotations
1920
from mcp.types import Tool as MCPTool
20-
from pydantic import Field, PydanticSchemaGenerationError
21+
from pydantic import Field, PydanticSchemaGenerationError, model_validator
2122
from typing_extensions import TypeVar
2223

2324
import fastmcp
@@ -130,6 +131,12 @@ class Tool(FastMCPComponent):
130131
Field(description="Optional custom serializer for tool results"),
131132
] = None
132133

134+
@model_validator(mode="after")
135+
def _validate_tool_name(self) -> Tool:
136+
"""Validate tool name according to MCP specification (SEP-986)."""
137+
validate_and_warn_tool_name(self.name)
138+
return self
139+
133140
def enable(self) -> None:
134141
super().enable()
135142
try:

tests/tools/test_tool.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1706,3 +1706,102 @@ def modulo(x: int, y: int) -> int:
17061706
# Should fall back to annotations.title
17071707
mcp_tool = tool.to_mcp_tool()
17081708
assert mcp_tool.title == "Annotation Title"
1709+
1710+
1711+
class TestToolNameValidation:
1712+
"""Tests for tool name validation per MCP specification (SEP-986)."""
1713+
1714+
@pytest.fixture
1715+
def caplog_for_mcp_validation(self, caplog):
1716+
"""Capture logs from the MCP SDK's tool name validation logger."""
1717+
import logging
1718+
1719+
caplog.set_level(logging.WARNING)
1720+
logger = logging.getLogger("mcp.shared.tool_name_validation")
1721+
original_level = logger.level
1722+
logger.setLevel(logging.WARNING)
1723+
logger.addHandler(caplog.handler)
1724+
try:
1725+
yield caplog
1726+
finally:
1727+
logger.removeHandler(caplog.handler)
1728+
logger.setLevel(original_level)
1729+
1730+
@pytest.mark.parametrize(
1731+
"name",
1732+
[
1733+
"valid_tool",
1734+
"valid-tool",
1735+
"valid.tool",
1736+
"ValidTool",
1737+
"tool123",
1738+
"a",
1739+
"a" * 128,
1740+
],
1741+
)
1742+
def test_valid_tool_names_no_warnings(self, name, caplog_for_mcp_validation):
1743+
"""Valid tool names should not produce warnings."""
1744+
1745+
def fn() -> str:
1746+
return "test"
1747+
1748+
tool = Tool.from_function(fn, name=name)
1749+
assert tool.name == name
1750+
assert "Tool name validation warning" not in caplog_for_mcp_validation.text
1751+
1752+
def test_tool_name_with_spaces_warns(self, caplog_for_mcp_validation):
1753+
"""Tool names with spaces should produce a warning."""
1754+
1755+
def fn() -> str:
1756+
return "test"
1757+
1758+
tool = Tool.from_function(fn, name="my tool")
1759+
assert tool.name == "my tool"
1760+
assert "Tool name validation warning" in caplog_for_mcp_validation.text
1761+
assert "contains spaces" in caplog_for_mcp_validation.text
1762+
1763+
def test_tool_name_with_invalid_chars_warns(self, caplog_for_mcp_validation):
1764+
"""Tool names with invalid characters should produce a warning."""
1765+
1766+
def fn() -> str:
1767+
return "test"
1768+
1769+
tool = Tool.from_function(fn, name="tool@name!")
1770+
assert tool.name == "tool@name!"
1771+
assert "Tool name validation warning" in caplog_for_mcp_validation.text
1772+
assert "invalid characters" in caplog_for_mcp_validation.text
1773+
1774+
def test_tool_name_too_long_warns(self, caplog_for_mcp_validation):
1775+
"""Tool names exceeding 128 characters should produce a warning."""
1776+
1777+
def fn() -> str:
1778+
return "test"
1779+
1780+
long_name = "a" * 129
1781+
tool = Tool.from_function(fn, name=long_name)
1782+
assert tool.name == long_name
1783+
assert "Tool name validation warning" in caplog_for_mcp_validation.text
1784+
assert "exceeds maximum length" in caplog_for_mcp_validation.text
1785+
1786+
def test_tool_name_with_leading_dash_warns(self, caplog_for_mcp_validation):
1787+
"""Tool names starting with dash should produce a warning."""
1788+
1789+
def fn() -> str:
1790+
return "test"
1791+
1792+
tool = Tool.from_function(fn, name="-tool")
1793+
assert tool.name == "-tool"
1794+
assert "Tool name validation warning" in caplog_for_mcp_validation.text
1795+
assert "starts or ends with a dash" in caplog_for_mcp_validation.text
1796+
1797+
def test_tool_still_created_despite_warnings(self, caplog_for_mcp_validation):
1798+
"""Tools with invalid names should still be created (SHOULD not MUST)."""
1799+
1800+
def add(a: int, b: int) -> int:
1801+
return a + b
1802+
1803+
tool = Tool.from_function(add, name="invalid tool name!")
1804+
assert tool.name == "invalid tool name!"
1805+
assert tool.parameters is not None
1806+
assert "a" in tool.parameters["properties"]
1807+
assert "b" in tool.parameters["properties"]

0 commit comments

Comments
 (0)