mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-12 08:39:09 +02:00
186 lines
7.3 KiB
Python
186 lines
7.3 KiB
Python
"""Tests for the ToolSearch plugin.
|
|
|
|
These exercise the plugin-facing API (`ToolSearch`, its `Config`,
|
|
registration on a server) rather than the underlying transform
|
|
internals, which live in `tests/server/transforms/test_search.py`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import warnings
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from fastmcp import Client, FastMCP
|
|
from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig
|
|
from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform
|
|
from fastmcp.server.plugins.tool_search.regex import RegexSearchTransform
|
|
|
|
|
|
def _make_server_with_tools(plugins: list) -> FastMCP:
|
|
mcp = FastMCP("t", plugins=plugins)
|
|
|
|
@mcp.tool
|
|
def add(a: int, b: int) -> int:
|
|
"""Add two numbers together."""
|
|
return a + b
|
|
|
|
@mcp.tool
|
|
def multiply(x: float, y: float) -> float:
|
|
"""Multiply two numbers."""
|
|
return x * y
|
|
|
|
@mcp.tool
|
|
def search_files(pattern: str) -> list[str]:
|
|
"""Search the filesystem for files matching a pattern."""
|
|
return []
|
|
|
|
return mcp
|
|
|
|
|
|
class TestSearchPluginRegistration:
|
|
async def test_default_plugin_uses_bm25_and_hides_tools(self):
|
|
"""With no config, ToolSearch uses BM25 and replaces list_tools output."""
|
|
mcp = _make_server_with_tools([ToolSearch()])
|
|
|
|
async with Client(mcp) as c:
|
|
tools = await c.list_tools()
|
|
names = {t.name for t in tools}
|
|
|
|
# Only the synthetic pair should be visible.
|
|
assert names == {"search_tools", "call_tool"}
|
|
|
|
async def test_regex_strategy_dispatches_regex_transform(self):
|
|
plugin = ToolSearch(ToolSearch.Config(strategy="regex"))
|
|
transforms = plugin.transforms()
|
|
assert len(transforms) == 1
|
|
assert isinstance(transforms[0], RegexSearchTransform)
|
|
|
|
async def test_bm25_strategy_dispatches_bm25_transform(self):
|
|
plugin = ToolSearch(ToolSearch.Config(strategy="bm25"))
|
|
transforms = plugin.transforms()
|
|
assert len(transforms) == 1
|
|
assert isinstance(transforms[0], BM25SearchTransform)
|
|
|
|
async def test_always_visible_pins_tools_alongside_search_call(self):
|
|
mcp = _make_server_with_tools(
|
|
[ToolSearch(ToolSearch.Config(always_visible=["add"]))]
|
|
)
|
|
|
|
async with Client(mcp) as c:
|
|
tools = await c.list_tools()
|
|
names = {t.name for t in tools}
|
|
|
|
assert names == {"add", "search_tools", "call_tool"}
|
|
|
|
async def test_custom_tool_names_apply(self):
|
|
mcp = _make_server_with_tools(
|
|
[
|
|
ToolSearch(
|
|
ToolSearch.Config(search_tool_name="find", call_tool_name="invoke")
|
|
)
|
|
]
|
|
)
|
|
|
|
async with Client(mcp) as c:
|
|
tools = await c.list_tools()
|
|
names = {t.name for t in tools}
|
|
by_name = {t.name: t for t in tools}
|
|
|
|
assert names == {"find", "invoke"}
|
|
# The call-tool proxy's description must reference the actual
|
|
# configured search-tool name, not the hardcoded "search_tools"
|
|
# default — otherwise LLMs see misleading guidance pointing at
|
|
# a tool that doesn't exist under the user's rename.
|
|
assert by_name["invoke"].description is not None
|
|
assert "find" in by_name["invoke"].description
|
|
assert "search_tools" not in by_name["invoke"].description
|
|
|
|
async def test_search_binds_searchconfig_via_generic_parameter(self):
|
|
"""`Plugin[ToolSearchConfig]` makes ToolSearchConfig the validated config type."""
|
|
assert ToolSearch._config_cls is ToolSearchConfig
|
|
assert ToolSearch.Config is ToolSearchConfig
|
|
|
|
async def test_dict_config_still_accepted(self):
|
|
"""Dict config path (inherited from Plugin base) constructs cleanly —
|
|
used for loading plugin configs from JSON/YAML."""
|
|
plugin = ToolSearch({"strategy": "regex"})
|
|
assert isinstance(plugin.transforms()[0], RegexSearchTransform)
|
|
|
|
async def test_hidden_tool_is_still_callable(self):
|
|
"""ToolSearch hides tools from list_tools but leaves them callable by name."""
|
|
mcp = _make_server_with_tools([ToolSearch()])
|
|
|
|
async with Client(mcp) as c:
|
|
result = await c.call_tool("add", {"a": 2, "b": 3})
|
|
assert result.data == 5
|
|
|
|
|
|
class TestSearchPluginConfigValidation:
|
|
def test_unknown_strategy_rejected(self):
|
|
with pytest.raises((ValidationError, Exception), match="strategy"):
|
|
ToolSearch.Config(strategy="fuzzy") # ty: ignore[invalid-argument-type]
|
|
|
|
def test_unknown_config_key_rejected(self):
|
|
with pytest.raises((ValidationError, Exception), match="forbid|extra"):
|
|
ToolSearch.Config(not_a_real_option=True) # ty: ignore[unknown-argument]
|
|
|
|
def test_default_meta_name_and_version(self):
|
|
"""ToolSearch relies on Plugin's auto-derived meta: kebab-cased
|
|
class name, no independent version (bundled first-party plugin)."""
|
|
assert ToolSearch.meta.name == "tool-search"
|
|
assert ToolSearch.meta.version is None
|
|
|
|
|
|
class TestDeprecationShim:
|
|
"""The old `fastmcp.server.transforms.search` path still works but warns."""
|
|
|
|
def test_old_package_import_emits_deprecation_warning(self):
|
|
# Force a fresh import so the module-level warning fires in this process.
|
|
import importlib
|
|
import sys
|
|
|
|
from fastmcp.exceptions import FastMCPDeprecationWarning
|
|
|
|
sys.modules.pop("fastmcp.server.transforms.search", None)
|
|
sys.modules.pop("fastmcp.server.transforms.search.base", None)
|
|
sys.modules.pop("fastmcp.server.transforms.search.bm25", None)
|
|
sys.modules.pop("fastmcp.server.transforms.search.regex", None)
|
|
|
|
with warnings.catch_warnings(record=True) as caught:
|
|
warnings.simplefilter("always")
|
|
importlib.import_module("fastmcp.server.transforms.search")
|
|
|
|
# Must be FastMCPDeprecationWarning specifically — fastmcp installs a
|
|
# filter that surfaces that subclass even when the base
|
|
# DeprecationWarning is suppressed by CPython's default filter.
|
|
fastmcp_deprecations = [
|
|
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
|
|
]
|
|
assert any(
|
|
"plugins.tool_search" in str(w.message) for w in fastmcp_deprecations
|
|
), (
|
|
f"expected FastMCPDeprecationWarning pointing at plugins.tool_search, "
|
|
f"got {[(w.category.__name__, str(w.message)) for w in caught]}"
|
|
)
|
|
|
|
def test_old_submodule_imports_still_resolve(self):
|
|
"""Existing code that imports from the old submodule path keeps working."""
|
|
from fastmcp.exceptions import FastMCPDeprecationWarning
|
|
|
|
# Suppress the parent-package deprecation warning that fires on first
|
|
# import — otherwise running this test in isolation leaks the warning
|
|
# to pytest output.
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", FastMCPDeprecationWarning)
|
|
from fastmcp.server.transforms.search.bm25 import (
|
|
BM25SearchTransform as OldBM25,
|
|
)
|
|
from fastmcp.server.transforms.search.regex import (
|
|
RegexSearchTransform as OldRegex,
|
|
)
|
|
|
|
# They're the same classes as the new path, not shims.
|
|
assert OldBM25 is BM25SearchTransform
|
|
assert OldRegex is RegexSearchTransform
|