mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
Convert code-mode to the CodeMode plugin (#4002)
This commit is contained in:
parent
3c0d248526
commit
19fa2fc33e
12 changed files with 1019 additions and 645 deletions
717
tests/server/plugins/test_code_mode.py
Normal file
717
tests/server/plugins/test_code_mode.py
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
import importlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp.types import ImageContent, TextContent
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.plugins.code_mode import (
|
||||
GetSchemas,
|
||||
GetToolCatalog,
|
||||
MontySandboxProvider,
|
||||
Search,
|
||||
)
|
||||
from fastmcp.server.plugins.code_mode.sandbox import _ensure_async
|
||||
from fastmcp.server.plugins.code_mode.transform import CodeModeTransform
|
||||
from fastmcp.tools.base import Tool, 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.
|
||||
|
||||
String results are wrapped in ``{"result": "..."}`` by the
|
||||
structured-output convention.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CodeMode core tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_code_mode_default_tools() -> None:
|
||||
"""Default CodeMode exposes search, get_schema, and execute."""
|
||||
mcp = FastMCP("CodeMode Default")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
listed_tools = await mcp.list_tools(run_middleware=False)
|
||||
assert {tool.name for tool in listed_tools} == {"search", "get_schema", "execute"}
|
||||
|
||||
|
||||
async def test_code_mode_search_returns_lightweight_results() -> None:
|
||||
"""Default search returns tool names and descriptions, not full schemas."""
|
||||
mcp = FastMCP("CodeMode Search")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
"""Compute the square of a number."""
|
||||
return x * x
|
||||
|
||||
@mcp.tool
|
||||
def greet(name: str) -> str:
|
||||
"""Say hello to someone."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(mcp, "search", {"query": "square number"})
|
||||
text = _unwrap_string_result(result)
|
||||
assert "square" in text
|
||||
assert "Compute the square" in text
|
||||
# Should NOT contain full schema details
|
||||
assert "inputSchema" not in text
|
||||
|
||||
|
||||
async def test_code_mode_get_schema_brief() -> None:
|
||||
"""get_schema with detail=brief returns names and descriptions only."""
|
||||
mcp = FastMCP("CodeMode Schema Brief")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
"""Compute the square of a number."""
|
||||
return x * x
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(
|
||||
mcp, "get_schema", {"tools": ["square"], "detail": "brief"}
|
||||
)
|
||||
text = _unwrap_string_result(result)
|
||||
assert "square" in text
|
||||
assert "Compute the square" in text
|
||||
# brief should NOT include parameter details
|
||||
assert "**Parameters**" not in text
|
||||
|
||||
|
||||
async def test_code_mode_get_schema_detailed() -> None:
|
||||
"""get_schema with detail=detailed returns markdown with parameter info."""
|
||||
mcp = FastMCP("CodeMode Schema Detailed")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
"""Compute the square of a number."""
|
||||
return x * x
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(
|
||||
mcp, "get_schema", {"tools": ["square"], "detail": "detailed"}
|
||||
)
|
||||
text = _unwrap_string_result(result)
|
||||
assert "### square" in text
|
||||
assert "Compute the square" in text
|
||||
assert "**Parameters**" in text
|
||||
assert "`x` (integer, required)" in text
|
||||
|
||||
|
||||
async def test_code_mode_get_schema_full() -> None:
|
||||
"""get_schema with detail=full returns JSON schema."""
|
||||
mcp = FastMCP("CodeMode Schema Full")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
"""Compute the square of a number."""
|
||||
return x * x
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(mcp, "get_schema", {"tools": ["square"], "detail": "full"})
|
||||
text = _unwrap_string_result(result)
|
||||
parsed = json.loads(text)
|
||||
assert isinstance(parsed, list)
|
||||
assert parsed[0]["name"] == "square"
|
||||
assert "inputSchema" in parsed[0]
|
||||
|
||||
|
||||
async def test_code_mode_get_schema_default_is_detailed() -> None:
|
||||
"""get_schema defaults to detailed (markdown with parameters)."""
|
||||
mcp = FastMCP("CodeMode Schema Default")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
"""Compute the square of a number."""
|
||||
return x * x
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(mcp, "get_schema", {"tools": ["square"]})
|
||||
text = _unwrap_string_result(result)
|
||||
assert "### square" in text
|
||||
assert "**Parameters**" in text
|
||||
|
||||
|
||||
async def test_code_mode_get_schema_not_found() -> None:
|
||||
"""get_schema reports tools that don't exist in the catalog."""
|
||||
mcp = FastMCP("CodeMode Schema NotFound")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(mcp, "get_schema", {"tools": ["nonexistent"]})
|
||||
text = _unwrap_string_result(result)
|
||||
assert "not found" in text.lower()
|
||||
assert "nonexistent" in text
|
||||
|
||||
|
||||
async def test_code_mode_get_schema_partial_match() -> None:
|
||||
"""get_schema returns schemas for found tools and reports missing ones."""
|
||||
mcp = FastMCP("CodeMode Schema Partial")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
"""Compute the square."""
|
||||
return x * x
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(mcp, "get_schema", {"tools": ["square", "nonexistent"]})
|
||||
text = _unwrap_string_result(result)
|
||||
assert "### square" in text
|
||||
assert "nonexistent" in text
|
||||
|
||||
|
||||
async def test_code_mode_execute_works() -> None:
|
||||
"""Execute tool can call backend tools through the sandbox."""
|
||||
mcp = FastMCP("CodeMode Execute")
|
||||
|
||||
@mcp.tool
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(
|
||||
mcp, "execute", {"code": "return await call_tool('add', {'x': 2, 'y': 3})"}
|
||||
)
|
||||
assert _unwrap_result(result) == {"result": 5}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool naming and configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_code_mode_custom_execute_name() -> None:
|
||||
mcp = FastMCP("CodeMode Custom Execute")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp.add_transform(
|
||||
CodeModeTransform(
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
execute_tool_name="run_code",
|
||||
)
|
||||
)
|
||||
|
||||
listed = await mcp.list_tools(run_middleware=False)
|
||||
names = {t.name for t in listed}
|
||||
assert "run_code" in names
|
||||
assert "execute" not in names
|
||||
|
||||
|
||||
async def test_code_mode_custom_execute_description() -> None:
|
||||
mcp = FastMCP("CodeMode Custom Desc")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp.add_transform(
|
||||
CodeModeTransform(
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
execute_description="Custom execute description",
|
||||
)
|
||||
)
|
||||
|
||||
listed = await mcp.list_tools(run_middleware=False)
|
||||
by_name = {t.name: t for t in listed}
|
||||
assert by_name["execute"].description == "Custom execute description"
|
||||
|
||||
|
||||
async def test_code_mode_default_execute_description() -> None:
|
||||
mcp = FastMCP("CodeMode Defaults")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
listed = await mcp.list_tools(run_middleware=False)
|
||||
by_name = {t.name: t for t in listed}
|
||||
desc = by_name["execute"].description or ""
|
||||
|
||||
assert "single block" in desc
|
||||
assert "Use `return` to produce output." in desc
|
||||
assert (
|
||||
"Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope."
|
||||
in desc
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery tool customization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_code_mode_no_discovery_tools() -> None:
|
||||
"""CodeMode with empty discovery_tools exposes only execute."""
|
||||
mcp = FastMCP("CodeMode No Discovery")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp.add_transform(
|
||||
CodeModeTransform(
|
||||
discovery_tools=[],
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
)
|
||||
)
|
||||
|
||||
listed = await mcp.list_tools(run_middleware=False)
|
||||
assert {t.name for t in listed} == {"execute"}
|
||||
|
||||
|
||||
async def test_code_mode_custom_discovery_tool_function() -> None:
|
||||
"""A plain function can serve as a discovery tool factory."""
|
||||
mcp = FastMCP("CodeMode Custom Discovery")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
"""Compute the square."""
|
||||
return x * x
|
||||
|
||||
def list_all(get_catalog: GetToolCatalog) -> Tool:
|
||||
async def list_tools(
|
||||
ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
|
||||
) -> str:
|
||||
"""List all available tools."""
|
||||
tools = await get_catalog(ctx)
|
||||
return ", ".join(t.name for t in tools)
|
||||
|
||||
return Tool.from_function(fn=list_tools, name="list_all")
|
||||
|
||||
mcp.add_transform(
|
||||
CodeModeTransform(
|
||||
discovery_tools=[list_all],
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
)
|
||||
)
|
||||
|
||||
listed = await mcp.list_tools(run_middleware=False)
|
||||
assert {t.name for t in listed} == {"list_all", "execute"}
|
||||
|
||||
result = await _run_tool(mcp, "list_all", {})
|
||||
text = _unwrap_string_result(result)
|
||||
assert "square" in text
|
||||
|
||||
|
||||
async def test_code_mode_search_detailed() -> None:
|
||||
"""Search with detail='detailed' returns markdown with parameter info."""
|
||||
mcp = FastMCP("CodeMode Search Detailed")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
"""Compute the square."""
|
||||
return x * x
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(mcp, "search", {"query": "square", "detail": "detailed"})
|
||||
text = _unwrap_string_result(result)
|
||||
assert "### square" in text
|
||||
assert "Compute the square" in text
|
||||
assert "**Parameters**" in text
|
||||
assert "`x` (integer, required)" in text
|
||||
|
||||
|
||||
async def test_code_mode_search_tool_full_detail() -> None:
|
||||
"""Search with detail='full' includes JSON schemas."""
|
||||
mcp = FastMCP("CodeMode Search Full")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
"""Compute the square."""
|
||||
return x * x
|
||||
|
||||
mcp.add_transform(
|
||||
CodeModeTransform(
|
||||
discovery_tools=[Search(default_detail="full")],
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
)
|
||||
)
|
||||
|
||||
result = await _run_tool(mcp, "search", {"query": "square"})
|
||||
text = _unwrap_string_result(result)
|
||||
parsed = json.loads(text)
|
||||
assert isinstance(parsed, list)
|
||||
assert parsed[0]["name"] == "square"
|
||||
assert "inputSchema" in parsed[0]
|
||||
|
||||
|
||||
async def test_code_mode_custom_search_tool_name() -> None:
|
||||
"""Search and GetSchemas support custom names."""
|
||||
mcp = FastMCP("CodeMode Custom Names")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp.add_transform(
|
||||
CodeModeTransform(
|
||||
discovery_tools=[
|
||||
Search(name="find"),
|
||||
GetSchemas(name="describe"),
|
||||
],
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
)
|
||||
)
|
||||
|
||||
listed = await mcp.list_tools(run_middleware=False)
|
||||
assert {t.name for t in listed} == {"find", "describe", "execute"}
|
||||
|
||||
|
||||
def test_code_mode_rejects_discovery_execute_name_collision() -> None:
|
||||
"""CodeMode raises ValueError when a discovery tool collides with execute."""
|
||||
cm = CodeModeTransform(
|
||||
discovery_tools=[Search(name="execute")],
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
)
|
||||
with pytest.raises(ValueError, match="collides"):
|
||||
cm._build_discovery_tools()
|
||||
|
||||
|
||||
def test_code_mode_rejects_duplicate_discovery_names() -> None:
|
||||
"""CodeMode raises ValueError when discovery tools have duplicate names."""
|
||||
cm = CodeModeTransform(
|
||||
discovery_tools=[Search(name="search"), Search(name="search")],
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
)
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
cm._build_discovery_tools()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Visibility and auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_code_mode_execute_respects_disabled_tool_visibility() -> None:
|
||||
mcp = FastMCP("CodeMode Disabled")
|
||||
|
||||
@mcp.tool
|
||||
def secret() -> str:
|
||||
return "nope"
|
||||
|
||||
mcp.disable(names={"secret"}, components={"tool"})
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
with pytest.raises(ToolError, match=r"Unknown tool"):
|
||||
await _run_tool(
|
||||
mcp, "execute", {"code": "return await call_tool('secret', {})"}
|
||||
)
|
||||
|
||||
|
||||
async def test_code_mode_search_respects_disabled_tool_visibility() -> None:
|
||||
mcp = FastMCP("CodeMode Disabled Search")
|
||||
|
||||
@mcp.tool
|
||||
def secret() -> str:
|
||||
"""A secret tool."""
|
||||
return "nope"
|
||||
|
||||
mcp.disable(names={"secret"}, components={"tool"})
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(mcp, "search", {"query": "secret"})
|
||||
text = _unwrap_string_result(result)
|
||||
assert "secret" not in text or "No tools" in text
|
||||
|
||||
|
||||
async def test_code_mode_execute_sees_mid_run_visibility_changes() -> None:
|
||||
"""Unlocking a tool mid-execution makes it callable in the same run."""
|
||||
mcp = FastMCP("CodeMode Unlock")
|
||||
|
||||
@mcp.tool
|
||||
async def unlock(ctx: Context) -> str:
|
||||
await ctx.enable_components(names={"secret"}, components={"tool"})
|
||||
return "unlocked"
|
||||
|
||||
@mcp.tool
|
||||
async def secret() -> str:
|
||||
return "secret-ok"
|
||||
|
||||
mcp.disable(names={"secret"}, components={"tool"})
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool(
|
||||
"execute",
|
||||
{
|
||||
"code": "await call_tool('unlock', {})\nreturn await call_tool('secret', {})"
|
||||
},
|
||||
)
|
||||
assert result.data == {"result": "secret-ok"}
|
||||
|
||||
|
||||
async def test_code_mode_execute_respects_tool_auth() -> None:
|
||||
mcp = FastMCP("CodeMode Auth")
|
||||
|
||||
@mcp.tool(auth=lambda _ctx: False)
|
||||
def protected() -> str:
|
||||
return "nope"
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
with pytest.raises(ToolError, match=r"Unknown tool"):
|
||||
await _run_tool(
|
||||
mcp, "execute", {"code": "return await call_tool('protected', {})"}
|
||||
)
|
||||
|
||||
|
||||
async def test_code_mode_search_respects_tool_auth() -> None:
|
||||
mcp = FastMCP("CodeMode Auth Search")
|
||||
|
||||
@mcp.tool(auth=lambda _ctx: False)
|
||||
def protected() -> str:
|
||||
"""A protected tool."""
|
||||
return "nope"
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(mcp, "search", {"query": "protected"})
|
||||
text = _unwrap_string_result(result)
|
||||
assert "protected" not in text or "No tools" in text
|
||||
|
||||
|
||||
async def test_code_mode_shadows_colliding_tool_names() -> None:
|
||||
"""Backend tools with the same name as meta-tools are shadowed."""
|
||||
mcp = FastMCP("CodeMode Collision")
|
||||
|
||||
@mcp.tool
|
||||
def search() -> str:
|
||||
return "real search"
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
tools = await mcp.list_tools(run_middleware=False)
|
||||
tool_names = {t.name for t in tools}
|
||||
assert "execute" in tool_names
|
||||
|
||||
result = await _run_tool(
|
||||
mcp, "execute", {"code": 'return await call_tool("ping", {})'}
|
||||
)
|
||||
assert _unwrap_result(result) == {"result": "pong"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_tool pass-through
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_code_mode_get_tool_returns_meta_tools_and_passes_through() -> None:
|
||||
"""get_tool returns meta-tools by name and passes through backend tools."""
|
||||
mcp = FastMCP("CodeMode GetTool")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
search_tool = await mcp.get_tool("search")
|
||||
assert search_tool is not None
|
||||
assert search_tool.name == "search"
|
||||
|
||||
schema_tool = await mcp.get_tool("get_schema")
|
||||
assert schema_tool is not None
|
||||
assert schema_tool.name == "get_schema"
|
||||
|
||||
execute_tool = await mcp.get_tool("execute")
|
||||
assert execute_tool is not None
|
||||
assert execute_tool.name == "execute"
|
||||
|
||||
ping_tool = await mcp.get_tool("ping")
|
||||
assert ping_tool is not None
|
||||
assert ping_tool.name == "ping"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execute edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_code_mode_execute_non_text_content_stringified() -> None:
|
||||
mcp = FastMCP("CodeMode NonText")
|
||||
|
||||
@mcp.tool
|
||||
def image_tool() -> ImageContent:
|
||||
return ImageContent(type="image", data="base64data", mimeType="image/png")
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(
|
||||
mcp, "execute", {"code": "return await call_tool('image_tool', {})"}
|
||||
)
|
||||
unwrapped = _unwrap_result(result)
|
||||
assert isinstance(unwrapped, str)
|
||||
assert "base64data" in unwrapped
|
||||
|
||||
|
||||
async def test_code_mode_execute_multi_tool_chaining() -> None:
|
||||
"""Execute block can chain multiple call_tool() calls."""
|
||||
mcp = FastMCP("CodeMode Chaining")
|
||||
|
||||
@mcp.tool
|
||||
def double(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
@mcp.tool
|
||||
def add_one(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
result = await _run_tool(
|
||||
mcp,
|
||||
"execute",
|
||||
{
|
||||
"code": (
|
||||
"a = await call_tool('double', {'x': 3})\n"
|
||||
"b = await call_tool('add_one', {'x': a['result']})\n"
|
||||
"return b"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert _unwrap_result(result) == {"result": 7}
|
||||
|
||||
|
||||
async def test_code_mode_sandbox_error_surfaces_as_tool_error() -> None:
|
||||
mcp = FastMCP("CodeMode Errors")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider()))
|
||||
|
||||
with pytest.raises(ToolError):
|
||||
await _run_tool(mcp, "execute", {"code": "raise ValueError('boom')"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox provider tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_monty_provider_raises_informative_error_when_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
provider = MontySandboxProvider()
|
||||
real_import_module = importlib.import_module
|
||||
|
||||
def _fake_import_module(name: str, package: str | None = None):
|
||||
if name == "pydantic_monty":
|
||||
raise ModuleNotFoundError("No module named 'pydantic_monty'")
|
||||
return real_import_module(name, package)
|
||||
|
||||
monkeypatch.setattr(importlib, "import_module", _fake_import_module)
|
||||
|
||||
with pytest.raises(ImportError, match=r"fastmcp\[code-mode\]"):
|
||||
await provider.run("return 1")
|
||||
|
||||
|
||||
async def test_monty_provider_forwards_limits() -> None:
|
||||
provider = MontySandboxProvider(limits={"max_duration_secs": 0.1})
|
||||
|
||||
with pytest.raises(Exception, match="time limit exceeded"):
|
||||
await provider.run("x = 0\nfor _ in range(10**9):\n x += 1")
|
||||
|
||||
|
||||
async def test_monty_provider_no_limits_by_default() -> None:
|
||||
provider = MontySandboxProvider()
|
||||
result = await provider.run("return 1 + 2")
|
||||
assert result == 3
|
||||
604
tests/server/plugins/test_code_mode_discovery.py
Normal file
604
tests/server/plugins/test_code_mode_discovery.py
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
from mcp.types import TextContent
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.code_mode import (
|
||||
GetTags,
|
||||
ListTools,
|
||||
Search,
|
||||
)
|
||||
from fastmcp.server.plugins.code_mode.sandbox import _ensure_async
|
||||
from fastmcp.server.plugins.code_mode.transform import CodeModeTransform
|
||||
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(
|
||||
CodeModeTransform(
|
||||
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(
|
||||
CodeModeTransform(
|
||||
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(
|
||||
CodeModeTransform(
|
||||
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(
|
||||
CodeModeTransform(
|
||||
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(
|
||||
CodeModeTransform(
|
||||
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(
|
||||
CodeModeTransform(
|
||||
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(CodeModeTransform(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(CodeModeTransform(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(CodeModeTransform(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(CodeModeTransform(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(
|
||||
CodeModeTransform(
|
||||
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(CodeModeTransform(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(CodeModeTransform(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(CodeModeTransform(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(CodeModeTransform(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(CodeModeTransform(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(CodeModeTransform(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(
|
||||
CodeModeTransform(
|
||||
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(
|
||||
CodeModeTransform(
|
||||
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(
|
||||
CodeModeTransform(
|
||||
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(
|
||||
CodeModeTransform(
|
||||
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(
|
||||
CodeModeTransform(
|
||||
discovery_tools=[ListTools()],
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
)
|
||||
)
|
||||
|
||||
result = await _run_tool(mcp, "list_tools", {})
|
||||
text = _unwrap_string_result(result)
|
||||
assert "No tools matched" in text
|
||||
105
tests/server/plugins/test_code_mode_plugin.py
Normal file
105
tests/server/plugins/test_code_mode_plugin.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Tests for the CodeMode plugin wrapper.
|
||||
|
||||
Transform behavior (what `CodeModeTransform` does to the catalog, how
|
||||
discovery tools render, sandbox execution, etc.) is covered by
|
||||
`test_code_mode.py` and `test_code_mode_discovery.py`. This file only
|
||||
covers the plugin layer itself — config validation, meta derivation,
|
||||
dict-config coercion, and the deprecation shim at the old import path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.code_mode import CodeMode, CodeModeConfig
|
||||
|
||||
|
||||
class _NoopSandbox:
|
||||
async def run(
|
||||
self,
|
||||
code: str,
|
||||
*,
|
||||
inputs: dict[str, Any] | None = None,
|
||||
external_functions: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
class TestCodeModeConfig:
|
||||
def test_config_generic_binding(self):
|
||||
"""`Plugin[CodeModeConfig]` binds CodeModeConfig as the validated config type."""
|
||||
assert CodeMode._config_cls is CodeModeConfig
|
||||
|
||||
def test_dict_config_accepted(self):
|
||||
"""Dict config works for loading from JSON/YAML."""
|
||||
plugin = CodeMode({"execute_tool_name": "go"})
|
||||
assert plugin.config.execute_tool_name == "go"
|
||||
|
||||
def test_unknown_sandbox_rejected(self):
|
||||
with pytest.raises((ValidationError, Exception), match="sandbox"):
|
||||
CodeModeConfig(sandbox="docker") # ty: ignore[invalid-argument-type]
|
||||
|
||||
def test_unknown_config_key_rejected(self):
|
||||
with pytest.raises((ValidationError, Exception), match="forbid|extra"):
|
||||
CodeModeConfig(not_a_real_option=True) # ty: ignore[unknown-argument]
|
||||
|
||||
def test_default_meta(self):
|
||||
"""CodeMode uses Plugin's auto-derived meta: kebab-cased class
|
||||
name, no independent version (bundled first-party plugin)."""
|
||||
assert CodeMode.meta.name == "code-mode"
|
||||
assert CodeMode.meta.version is None
|
||||
|
||||
|
||||
class TestDeprecationShim:
|
||||
"""The old `fastmcp.experimental.transforms.code_mode` path still works but warns."""
|
||||
|
||||
def test_old_package_import_emits_deprecation_warning(self):
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
sys.modules.pop("fastmcp.experimental.transforms.code_mode", None)
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
importlib.import_module("fastmcp.experimental.transforms.code_mode")
|
||||
|
||||
fastmcp_deprecations = [
|
||||
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
|
||||
]
|
||||
assert any(
|
||||
"plugins.code_mode" in str(w.message) for w in fastmcp_deprecations
|
||||
), (
|
||||
f"expected FastMCPDeprecationWarning pointing at plugins.code_mode, "
|
||||
f"got {[(w.category.__name__, str(w.message)) for w in caught]}"
|
||||
)
|
||||
|
||||
async def test_legacy_add_transform_pattern_still_works(self):
|
||||
"""End-to-end: old `add_transform(CodeMode(...))` code keeps
|
||||
working. The point of the shim is that this doesn't break — the
|
||||
identity-check test alone wouldn't catch a regression where
|
||||
`CodeMode` at the old path drifted to the plugin class."""
|
||||
from fastmcp.exceptions import FastMCPDeprecationWarning
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", FastMCPDeprecationWarning)
|
||||
from fastmcp.experimental.transforms.code_mode import (
|
||||
CodeMode as OldCodeMode,
|
||||
)
|
||||
|
||||
mcp = FastMCP("legacy")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> str:
|
||||
return "pong"
|
||||
|
||||
mcp.add_transform(OldCodeMode(sandbox_provider=_NoopSandbox()))
|
||||
|
||||
tools = await mcp.list_tools(run_middleware=False)
|
||||
assert {t.name for t in tools} == {"search", "get_schema", "execute"}
|
||||
197
tests/server/plugins/test_code_mode_serialization.py
Normal file
197
tests/server/plugins/test_code_mode_serialization.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.plugins.tool_search.base import (
|
||||
_schema_section,
|
||||
_schema_type,
|
||||
serialize_tools_for_output_markdown,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _schema_type unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema,expected",
|
||||
[
|
||||
({"type": "string"}, "string"),
|
||||
({"type": "integer"}, "integer"),
|
||||
({"type": "boolean"}, "boolean"),
|
||||
({"type": "null"}, "null"),
|
||||
({"type": "array", "items": {"type": "string"}}, "string[]"),
|
||||
({"type": "array", "items": {"type": "integer"}}, "integer[]"),
|
||||
({"type": "array"}, "any[]"),
|
||||
({"$ref": "#/$defs/Foo"}, "object"),
|
||||
({"properties": {"x": {"type": "int"}}}, "object"),
|
||||
({}, "any"),
|
||||
(None, "any"),
|
||||
("not a dict", "any"),
|
||||
],
|
||||
)
|
||||
def test_schema_type_basic(schema: Any, expected: str) -> None:
|
||||
assert _schema_type(schema) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema,expected",
|
||||
[
|
||||
({"anyOf": [{"type": "string"}, {"type": "null"}]}, "string?"),
|
||||
({"anyOf": [{"type": "string"}, {"type": "integer"}]}, "string | integer"),
|
||||
(
|
||||
{"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]},
|
||||
"string | integer?",
|
||||
),
|
||||
({"anyOf": [{"type": "null"}]}, "null"),
|
||||
({"anyOf": []}, "any"),
|
||||
({"oneOf": [{"type": "string"}, {"type": "null"}]}, "string?"),
|
||||
({"oneOf": [{"type": "string"}, {"type": "integer"}]}, "string | integer"),
|
||||
({"allOf": [{"type": "object"}]}, "object"),
|
||||
({"allOf": [{"$ref": "#/$defs/Foo"}, {"$ref": "#/$defs/Bar"}]}, "object"),
|
||||
],
|
||||
)
|
||||
def test_schema_type_unions(schema: Any, expected: str) -> None:
|
||||
assert _schema_type(schema) == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _schema_section unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema,expected_lines",
|
||||
[
|
||||
(None, ["**Parameters**", "- `value` (any)"]),
|
||||
("string", ["**Parameters**", "- `value` (any)"]),
|
||||
({"type": "string"}, ["**Parameters**", "- `value` (string)"]),
|
||||
(
|
||||
{"type": "object", "properties": {}},
|
||||
["**Parameters**", "*(no parameters)*"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_schema_section_fallbacks(schema: Any, expected_lines: list[str]) -> None:
|
||||
assert _schema_section(schema, "Parameters") == expected_lines
|
||||
|
||||
|
||||
def test_schema_section_lists_fields_with_required_marker() -> None:
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer"},
|
||||
},
|
||||
"required": ["name"],
|
||||
}
|
||||
lines = _schema_section(schema, "Parameters")
|
||||
assert lines[0] == "**Parameters**"
|
||||
assert "- `name` (string, required)" in lines
|
||||
assert "- `age` (integer)" in lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# serialize_tools_for_output_markdown unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_serialize_tools_for_output_markdown_empty_list() -> None:
|
||||
assert serialize_tools_for_output_markdown([]) == "No tools matched the query."
|
||||
|
||||
|
||||
async def test_serialize_tools_for_output_markdown_basic_tool() -> None:
|
||||
mcp = FastMCP("MD Basic")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
"""Compute the square of a number."""
|
||||
return x * x
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
result = serialize_tools_for_output_markdown(tools)
|
||||
|
||||
assert "### square" in result
|
||||
assert "Compute the square of a number." in result
|
||||
assert "**Parameters**" in result
|
||||
assert "`x` (integer, required)" in result
|
||||
|
||||
|
||||
async def test_serialize_tools_for_output_markdown_omits_output_section_when_no_schema() -> (
|
||||
None
|
||||
):
|
||||
mcp = FastMCP("MD No Output")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> None:
|
||||
pass
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
result = serialize_tools_for_output_markdown(tools)
|
||||
|
||||
assert "**Returns**" not in result
|
||||
|
||||
|
||||
async def test_serialize_tools_for_output_markdown_includes_output_section_when_schema_present() -> (
|
||||
None
|
||||
):
|
||||
mcp = FastMCP("MD With Output")
|
||||
|
||||
@mcp.tool
|
||||
def double(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
result = serialize_tools_for_output_markdown(tools)
|
||||
|
||||
assert "**Returns**" in result
|
||||
|
||||
|
||||
async def test_serialize_tools_for_output_markdown_omits_description_when_absent() -> (
|
||||
None
|
||||
):
|
||||
mcp = FastMCP("MD No Desc")
|
||||
|
||||
@mcp.tool
|
||||
def ping() -> None:
|
||||
pass
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
result = serialize_tools_for_output_markdown(tools)
|
||||
|
||||
assert "### ping" in result
|
||||
|
||||
|
||||
async def test_serialize_tools_for_output_markdown_optional_field_uses_question_mark() -> (
|
||||
None
|
||||
):
|
||||
mcp = FastMCP("MD Optional")
|
||||
|
||||
@mcp.tool
|
||||
def greet(name: str, greeting: str | None = None) -> str:
|
||||
return f"{greeting or 'Hello'}, {name}!"
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
result = serialize_tools_for_output_markdown(tools)
|
||||
|
||||
assert "`greeting` (string?)" in result
|
||||
|
||||
|
||||
async def test_serialize_tools_for_output_markdown_multiple_tools_separated() -> None:
|
||||
mcp = FastMCP("MD Multi")
|
||||
|
||||
@mcp.tool
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
@mcp.tool
|
||||
def subtract(a: int, b: int) -> int:
|
||||
return a - b
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
result = serialize_tools_for_output_markdown(tools)
|
||||
|
||||
assert "### add" in result
|
||||
assert "### subtract" in result
|
||||
assert "\n\n" in result
|
||||
Loading…
Add table
Add a link
Reference in a new issue