import json from typing import Any from mcp_types import TextContent from fastmcp import FastMCP from fastmcp.experimental.transforms.code_mode import ( CodeMode, GetTags, ListTools, Search, _ensure_async, ) from fastmcp.tools.base import ToolResult def _unwrap_result(result: ToolResult) -> Any: """Extract the logical return value from a ToolResult.""" if result.structured_content is not None: return result.structured_content text_blocks = [ content.text for content in result.content if isinstance(content, TextContent) ] if not text_blocks: return None if len(text_blocks) == 1: try: return json.loads(text_blocks[0]) except json.JSONDecodeError: return text_blocks[0] values: list[Any] = [] for text in text_blocks: try: values.append(json.loads(text)) except json.JSONDecodeError: values.append(text) return values def _unwrap_string_result(result: ToolResult) -> str: """Extract a string result from a ToolResult.""" data = _unwrap_result(result) if isinstance(data, dict) and "result" in data: return data["result"] assert isinstance(data, str) return data class _UnsafeTestSandboxProvider: """UNSAFE: Uses exec() for testing only. Never use in production.""" async def run( self, code: str, *, inputs: dict[str, Any] | None = None, external_functions: dict[str, Any] | None = None, ) -> Any: namespace: dict[str, Any] = {} if inputs: namespace.update(inputs) if external_functions: namespace.update( {key: _ensure_async(value) for key, value in external_functions.items()} ) wrapped = "async def __test_main__():\n" for line in code.splitlines(): wrapped += f" {line}\n" if not code.strip(): wrapped += " return None\n" exec(wrapped, namespace, namespace) return await namespace["__test_main__"]() async def _run_tool( server: FastMCP, name: str, arguments: dict[str, Any] ) -> ToolResult: return await server.call_tool(name, arguments) # --------------------------------------------------------------------------- # Tags discovery tool # --------------------------------------------------------------------------- async def test_categories_brief_shows_tag_counts() -> None: mcp = FastMCP("Tags Brief") @mcp.tool(tags={"math"}) def add(x: int, y: int) -> int: return x + y @mcp.tool(tags={"math"}) def multiply(x: int, y: int) -> int: return x * y @mcp.tool(tags={"text"}) def greet(name: str) -> str: return f"Hello, {name}!" mcp.add_transform( CodeMode( discovery_tools=[GetTags()], sandbox_provider=_UnsafeTestSandboxProvider(), ) ) result = await _run_tool(mcp, "tags", {}) text = _unwrap_string_result(result) assert "math (2 tools)" in text assert "text (1 tool)" in text async def test_categories_full_lists_tools_per_tag() -> None: mcp = FastMCP("Tags Full") @mcp.tool(tags={"math"}) def add(x: int, y: int) -> int: """Add two numbers.""" return x + y @mcp.tool(tags={"text"}) def greet(name: str) -> str: """Say hello.""" return f"Hello, {name}!" mcp.add_transform( CodeMode( discovery_tools=[GetTags(default_detail="full")], sandbox_provider=_UnsafeTestSandboxProvider(), ) ) result = await _run_tool(mcp, "tags", {}) text = _unwrap_string_result(result) assert "### math" in text assert "- add: Add two numbers." in text assert "### text" in text assert "- greet: Say hello." in text async def test_categories_includes_untagged() -> None: mcp = FastMCP("Tags Untagged") @mcp.tool(tags={"math"}) def add(x: int, y: int) -> int: return x + y @mcp.tool def ping() -> str: return "pong" mcp.add_transform( CodeMode( discovery_tools=[GetTags()], sandbox_provider=_UnsafeTestSandboxProvider(), ) ) result = await _run_tool(mcp, "tags", {}) text = _unwrap_string_result(result) assert "math" in text assert "untagged (1 tool)" in text async def test_categories_tool_in_multiple_tags() -> None: mcp = FastMCP("Tags Multi-tag") @mcp.tool(tags={"math", "core"}) def add(x: int, y: int) -> int: return x + y mcp.add_transform( CodeMode( discovery_tools=[GetTags(default_detail="full")], sandbox_provider=_UnsafeTestSandboxProvider(), ) ) result = await _run_tool(mcp, "tags", {}) text = _unwrap_string_result(result) assert "### core" in text assert "### math" in text # Tool appears under both tags assert text.count("- add") == 2 async def test_categories_detail_override_per_call() -> None: """LLM can override default_detail on a per-call basis.""" mcp = FastMCP("Tags Override") @mcp.tool(tags={"math"}) def add(x: int, y: int) -> int: """Add numbers.""" return x + y mcp.add_transform( CodeMode( discovery_tools=[GetTags()], # default_detail="brief" sandbox_provider=_UnsafeTestSandboxProvider(), ) ) # Override to full result = await _run_tool(mcp, "tags", {"detail": "full"}) text = _unwrap_string_result(result) assert "### math" in text assert "- add: Add numbers." in text async def test_get_tags_empty_catalog() -> None: """GetTags with no tools returns 'No tools available.'.""" mcp = FastMCP("CodeMode Empty Tags") mcp.disable(names={"ping"}, components={"tool"}) mcp.add_transform( CodeMode( discovery_tools=[GetTags()], sandbox_provider=_UnsafeTestSandboxProvider(), ) ) result = await _run_tool(mcp, "tags", {}) text = _unwrap_string_result(result) assert "No tools available" in text # --------------------------------------------------------------------------- # Search with tags filtering # --------------------------------------------------------------------------- async def test_search_with_tags_filter() -> None: mcp = FastMCP("Search Tags") @mcp.tool(tags={"math"}) def add(x: int, y: int) -> int: """Add two numbers.""" return x + y @mcp.tool(tags={"text"}) def greet(name: str) -> str: """Say hello.""" return f"Hello, {name}!" mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "add hello", "tags": ["math"]}) text = _unwrap_string_result(result) assert "add" in text assert "greet" not in text async def test_search_with_tags_filter_no_matches() -> None: mcp = FastMCP("Search Tags Empty") @mcp.tool(tags={"math"}) def add(x: int, y: int) -> int: """Add two numbers.""" return x + y mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "add", "tags": ["nonexistent"]}) text = _unwrap_string_result(result) assert "add" not in text or "No tools" in text async def test_search_without_tags_returns_all() -> None: """Search without tags parameter searches the full catalog.""" mcp = FastMCP("Search No Tags") @mcp.tool(tags={"math"}) def add(x: int, y: int) -> int: """Add two numbers.""" return x + y @mcp.tool(tags={"text"}) def greet(name: str) -> str: """Say hello.""" return f"Hello, {name}!" mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "add hello"}) text = _unwrap_string_result(result) assert "add" in text assert "greet" in text async def test_search_with_untagged_filter() -> None: """Search with tags=["untagged"] matches tools that have no tags.""" mcp = FastMCP("Search Untagged") @mcp.tool(tags={"math"}) def add(x: int, y: int) -> int: """Add two numbers.""" return x + y @mcp.tool def ping() -> str: """Ping.""" return "pong" mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "ping add", "tags": ["untagged"]}) text = _unwrap_string_result(result) assert "ping" in text assert "add" not in text async def test_search_default_detail_detailed_skips_get_schema() -> None: """Two-stage pattern: Search(default_detail='detailed') returns schemas inline.""" mcp = FastMCP("CodeMode Two-Stage") @mcp.tool def square(x: int) -> int: """Compute the square.""" return x * x mcp.add_transform( CodeMode( discovery_tools=[Search(default_detail="detailed")], sandbox_provider=_UnsafeTestSandboxProvider(), ) ) result = await _run_tool(mcp, "search", {"query": "square"}) text = _unwrap_string_result(result) assert "### square" in text assert "**Parameters**" in text assert "`x` (integer, required)" in text async def test_search_full_detail_empty_results_returns_json() -> None: """Search with detail=full and no matches returns valid JSON, not plain text.""" mcp = FastMCP("CodeMode Empty Full") @mcp.tool(tags={"math"}) def add(x: int, y: int) -> int: return x + y mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool( mcp, "search", {"query": "nonexistent", "tags": ["nonexistent"], "detail": "full"}, ) text = _unwrap_string_result(result) parsed = json.loads(text) assert parsed == [] async def test_get_schema_empty_tools_list() -> None: """get_schema with an empty tools list returns no-match message.""" mcp = FastMCP("CodeMode Empty Schema") @mcp.tool def ping() -> str: return "pong" mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "get_schema", {"tools": []}) text = _unwrap_string_result(result) assert "No tools matched" in text async def test_get_schema_full_partial_match_returns_valid_json() -> None: """get_schema with detail=full and missing tools returns valid JSON.""" mcp = FastMCP("Schema Full Partial") @mcp.tool def square(x: int) -> int: """Compute the square.""" return x * x mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool( mcp, "get_schema", {"tools": ["square", "nonexistent"], "detail": "full"} ) text = _unwrap_string_result(result) parsed = json.loads(text) assert isinstance(parsed, list) assert parsed[0]["name"] == "square" assert parsed[-1] == {"not_found": ["nonexistent"]} # --------------------------------------------------------------------------- # Search catalog size annotation # --------------------------------------------------------------------------- async def test_search_shows_catalog_size_when_results_are_subset() -> None: """Search annotates results with 'N of M tools' when not all tools match.""" mcp = FastMCP("Search Annotation") @mcp.tool def add(x: int, y: int) -> int: """Add two numbers.""" return x + y @mcp.tool def multiply(x: int, y: int) -> int: """Multiply two numbers.""" return x * y @mcp.tool def greet(name: str) -> str: """Say hello.""" return f"Hello, {name}!" mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "add numbers"}) text = _unwrap_string_result(result) # Should show partial result count out of total catalog assert "of 3 tools:" in text async def test_search_omits_annotation_when_all_tools_returned() -> None: """Search does not annotate when results include every tool.""" mcp = FastMCP("Search All Match") @mcp.tool def add(x: int, y: int) -> int: """Add two numbers.""" return x + y mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "add numbers"}) text = _unwrap_string_result(result) assert "of" not in text or "tools:" not in text # --------------------------------------------------------------------------- # Search limit # --------------------------------------------------------------------------- async def test_search_limit_caps_results() -> None: """Search with limit returns at most that many results.""" mcp = FastMCP("Search Limit") @mcp.tool def add(x: int, y: int) -> int: """Add numbers.""" return x + y @mcp.tool def subtract(x: int, y: int) -> int: """Subtract numbers.""" return x - y @mcp.tool def multiply(x: int, y: int) -> int: """Multiply numbers.""" return x * y mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "numbers", "limit": 1}) text = _unwrap_string_result(result) assert "1 of 3 tools:" in text # Only one tool line (starts with "- ") tool_lines = [line for line in text.splitlines() if line.startswith("- ")] assert len(tool_lines) == 1 async def test_search_default_limit_from_constructor() -> None: """Search(default_limit=N) caps results by default.""" mcp = FastMCP("Search Default Limit") @mcp.tool def a() -> str: """Tool A.""" return "a" @mcp.tool def b() -> str: """Tool B.""" return "b" @mcp.tool def c() -> str: """Tool C.""" return "c" mcp.add_transform( CodeMode( discovery_tools=[Search(default_limit=2)], sandbox_provider=_UnsafeTestSandboxProvider(), ) ) result = await _run_tool(mcp, "search", {"query": "tool"}) text = _unwrap_string_result(result) assert "2 of 3 tools:" in text # --------------------------------------------------------------------------- # ListTools discovery tool # --------------------------------------------------------------------------- async def test_list_tools_brief() -> None: """ListTools at brief detail lists all tool names and descriptions.""" mcp = FastMCP("ListTools Brief") @mcp.tool def add(x: int, y: int) -> int: """Add two numbers.""" return x + y @mcp.tool def multiply(x: int, y: int) -> int: """Multiply two numbers.""" return x * y mcp.add_transform( CodeMode( discovery_tools=[ListTools()], sandbox_provider=_UnsafeTestSandboxProvider(), ) ) result = await _run_tool(mcp, "list_tools", {}) text = _unwrap_string_result(result) assert "add" in text assert "multiply" in text assert "Add two numbers" in text # brief should not include parameter details assert "**Parameters**" not in text async def test_list_tools_detailed() -> None: """ListTools at detailed shows parameter schemas.""" mcp = FastMCP("ListTools Detailed") @mcp.tool def square(x: int) -> int: """Compute the square.""" return x * x mcp.add_transform( CodeMode( discovery_tools=[ListTools(default_detail="detailed")], sandbox_provider=_UnsafeTestSandboxProvider(), ) ) result = await _run_tool(mcp, "list_tools", {}) text = _unwrap_string_result(result) assert "### square" in text assert "**Parameters**" in text assert "`x` (integer, required)" in text async def test_list_tools_full_returns_json() -> None: """ListTools at full returns valid JSON with schemas.""" mcp = FastMCP("ListTools Full") @mcp.tool def ping() -> str: """Ping.""" return "pong" mcp.add_transform( CodeMode( discovery_tools=[ListTools()], sandbox_provider=_UnsafeTestSandboxProvider(), ) ) result = await _run_tool(mcp, "list_tools", {"detail": "full"}) text = _unwrap_string_result(result) parsed = json.loads(text) assert isinstance(parsed, list) assert parsed[0]["name"] == "ping" async def test_list_tools_empty_catalog() -> None: """ListTools with no tools returns no-match message.""" mcp = FastMCP("ListTools Empty") mcp.add_transform( CodeMode( discovery_tools=[ListTools()], sandbox_provider=_UnsafeTestSandboxProvider(), ) ) result = await _run_tool(mcp, "list_tools", {}) text = _unwrap_string_result(result) assert "No tools matched" in text