mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-25 15:04:18 +02:00
* code mode * update uv.lock for monty optional dep 🤖 Generated with Claude Code * retry CI * Address PR review comments on CodeMode transform 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix ty unresolved-attribute error on search_helper 🤖 Generated with Claude Code Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * more idiomacy * harden * fix docs * harden * fix red CI * Refactor CodeMode to use CatalogTransform base class Removes the duplicate ContextVar bypass pattern in favor of the shared CatalogTransform machinery. Also fixes a pre-existing bug where `from __future__ import annotations` caused NameError for Annotated in nested function scopes at runtime. * Remove redundant _get_visible_tools wrapper in CodeMode * Rewrite CodeMode docs with proper motivation and structure * Fix type narrowing in collision test * Stop unwrapping tool results in CodeMode's call_tool call_tool() inside execute blocks now returns structured content as-is, preserving the {"result": value} wrapping. This means the output schema shown in search results accurately describes what call_tool() returns, so LLMs can trust the schema when writing code. Also adds examples/code_mode/ with a server and narrated client demo. * Simplify call_tool return type: dict | str * Fix example client to unwrap structured results * Let server resolve tool versions instead of pinning first match * Rewrite CodeMode docs to match current behavior * Rename optional extra from monty to code-mode --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
456 lines
13 KiB
Python
456 lines
13 KiB
Python
import importlib
|
|
import json
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from mcp.types import ImageContent, TextContent
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.exceptions import ToolError
|
|
from fastmcp.experimental.transforms import CodeMode, MontySandboxProvider
|
|
from fastmcp.experimental.transforms.code_mode import _ensure_async
|
|
from fastmcp.tools.tool 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_search_results(result: ToolResult) -> list[dict[str, Any]]:
|
|
"""Extract the list of tool dicts from a search ToolResult.
|
|
|
|
The search tool returns ``list[dict]`` which gets wrapped in
|
|
``{"result": [...]}`` by the structured-output convention.
|
|
"""
|
|
data = _unwrap_result(result)
|
|
if isinstance(data, dict) and "result" in data:
|
|
return data["result"]
|
|
if isinstance(data, list):
|
|
return data
|
|
raise AssertionError(f"Unexpected search result shape: {data!r}")
|
|
|
|
|
|
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)
|
|
|
|
|
|
async def test_code_mode_transform_hides_backend_tools_and_supports_defaults() -> None:
|
|
mcp = FastMCP("CodeMode Test")
|
|
|
|
@mcp.tool
|
|
def add(x: int, y: int, workspace_id: str) -> str:
|
|
"""Add two numbers with workspace context."""
|
|
return f"{workspace_id}:{x + y}"
|
|
|
|
@mcp.tool
|
|
def status() -> str:
|
|
"""Get current status."""
|
|
return "ok"
|
|
|
|
mcp.add_transform(
|
|
CodeMode(
|
|
default_arguments={"workspace_id": "ws-default"},
|
|
sandbox_provider=_UnsafeTestSandboxProvider(),
|
|
)
|
|
)
|
|
|
|
listed_tools = await mcp.list_tools(run_middleware=False)
|
|
assert {tool.name for tool in listed_tools} == {"search", "execute"}
|
|
|
|
search_result = await _run_tool(mcp, "search", {"query": "add numbers"})
|
|
names = [t["name"] for t in _unwrap_search_results(search_result)]
|
|
assert "add" in names
|
|
|
|
execute_result = await _run_tool(
|
|
mcp,
|
|
"execute",
|
|
{"code": "return await call_tool('add', {'x': 2, 'y': 3})"},
|
|
)
|
|
assert _unwrap_result(execute_result) == {"result": "ws-default:5"}
|
|
|
|
status_result = await _run_tool(
|
|
mcp,
|
|
"execute",
|
|
{"code": "return await call_tool('status', {})"},
|
|
)
|
|
assert _unwrap_result(status_result) == {"result": "ok"}
|
|
|
|
|
|
async def test_code_mode_transform_replaces_listed_tools() -> None:
|
|
mcp = FastMCP("CodeMode Transform")
|
|
|
|
@mcp.tool
|
|
def ping() -> str:
|
|
return "pong"
|
|
|
|
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
|
|
|
|
listed_tools = await mcp.list_tools(run_middleware=False)
|
|
assert {tool.name for tool in listed_tools} == {"search", "execute"}
|
|
|
|
|
|
async def test_code_mode_tool_descriptions_are_configurable() -> None:
|
|
mcp = FastMCP("CodeMode Descriptions")
|
|
|
|
@mcp.tool
|
|
def ping() -> str:
|
|
return "pong"
|
|
|
|
mcp.add_transform(
|
|
CodeMode(
|
|
sandbox_provider=_UnsafeTestSandboxProvider(),
|
|
search_tool_name="search_meta",
|
|
execute_tool_name="execute_meta",
|
|
execute_description="Custom execute description",
|
|
)
|
|
)
|
|
|
|
listed_tools = await mcp.list_tools(run_middleware=False)
|
|
by_name = {tool.name: tool for tool in listed_tools}
|
|
|
|
assert by_name["execute_meta"].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(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
|
|
|
|
listed_tools = await mcp.list_tools(run_middleware=False)
|
|
by_name = {tool.name: tool for tool in listed_tools}
|
|
|
|
execute_description = by_name["execute"].description or ""
|
|
|
|
assert "single block" in execute_description
|
|
assert "Use `return` to produce output." in execute_description
|
|
assert (
|
|
"Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope."
|
|
in execute_description
|
|
)
|
|
|
|
|
|
async def test_code_mode_search_returns_matching_tools() -> None:
|
|
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(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
|
|
|
|
result = await _run_tool(mcp, "search", {"query": "square number"})
|
|
tools = _unwrap_search_results(result)
|
|
assert len(tools) > 0
|
|
assert tools[0]["name"] == "square"
|
|
|
|
|
|
async def test_code_mode_search_results_include_schema() -> None:
|
|
mcp = FastMCP("CodeMode Output Schema")
|
|
|
|
@mcp.tool
|
|
def square(x: int) -> int:
|
|
"""Compute the square of a number."""
|
|
return x * x
|
|
|
|
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
|
|
|
|
result = await _run_tool(mcp, "search", {"query": "square"})
|
|
tools = _unwrap_search_results(result)
|
|
assert len(tools) > 0
|
|
tool_dict = tools[0]
|
|
assert "inputSchema" in tool_dict
|
|
|
|
|
|
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(CodeMode(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(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
|
|
|
|
result = await _run_tool(mcp, "search", {"query": "secret"})
|
|
tools = _unwrap_search_results(result)
|
|
assert tools == []
|
|
|
|
|
|
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(CodeMode(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(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
|
|
|
|
result = await _run_tool(mcp, "search", {"query": "protected"})
|
|
tools = _unwrap_search_results(result)
|
|
assert tools == []
|
|
|
|
|
|
async def test_code_mode_shadows_colliding_tool_names() -> None:
|
|
"""Backend tools with the same name as meta-tools are shadowed, not rejected."""
|
|
mcp = FastMCP("CodeMode Collision")
|
|
|
|
@mcp.tool
|
|
def search() -> str:
|
|
return "real search"
|
|
|
|
@mcp.tool
|
|
def ping() -> str:
|
|
return "pong"
|
|
|
|
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
|
|
|
|
tools = await mcp.list_tools(run_middleware=False)
|
|
tool_names = {t.name for t in tools}
|
|
assert tool_names == {"search", "execute"}
|
|
|
|
result = await _run_tool(
|
|
mcp, "execute", {"code": 'return await call_tool("ping", {})'}
|
|
)
|
|
assert _unwrap_result(result) == {"result": "pong"}
|
|
|
|
|
|
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(CodeMode(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_monty_provider_raises_informative_error_when_missing(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
provider = MontySandboxProvider(install_hint="fastmcp[code-mode]")
|
|
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_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(CodeMode(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_execute_default_arguments_overridden_by_explicit() -> None:
|
|
"""Explicit params in call_tool() override default_arguments."""
|
|
mcp = FastMCP("CodeMode Override")
|
|
|
|
@mcp.tool
|
|
def greet(name: str, greeting: str) -> str:
|
|
return f"{greeting}, {name}!"
|
|
|
|
mcp.add_transform(
|
|
CodeMode(
|
|
default_arguments={"greeting": "Hello"},
|
|
sandbox_provider=_UnsafeTestSandboxProvider(),
|
|
)
|
|
)
|
|
|
|
result = await _run_tool(
|
|
mcp,
|
|
"execute",
|
|
{"code": "return await call_tool('greet', {'name': 'World'})"},
|
|
)
|
|
assert _unwrap_result(result) == {"result": "Hello, World!"}
|
|
|
|
result = await _run_tool(
|
|
mcp,
|
|
"execute",
|
|
{
|
|
"code": "return await call_tool('greet', {'name': 'World', 'greeting': 'Hi'})"
|
|
},
|
|
)
|
|
assert _unwrap_result(result) == {"result": "Hi, World!"}
|
|
|
|
|
|
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(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
|
|
|
|
search_tool = await mcp.get_tool("search")
|
|
assert search_tool is not None
|
|
assert search_tool.name == "search"
|
|
|
|
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"
|
|
|
|
|
|
async def test_code_mode_sandbox_error_surfaces_as_tool_error() -> None:
|
|
"""Runtime errors in sandbox code surface as ToolError."""
|
|
mcp = FastMCP("CodeMode Errors")
|
|
|
|
@mcp.tool
|
|
def ping() -> str:
|
|
return "pong"
|
|
|
|
mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider()))
|
|
|
|
with pytest.raises(ToolError):
|
|
await _run_tool(mcp, "execute", {"code": "raise ValueError('boom')"})
|
|
|
|
|
|
def test_code_mode_rejects_identical_tool_names() -> None:
|
|
"""CodeMode raises ValueError when search and execute names collide."""
|
|
with pytest.raises(ValueError, match="must be different"):
|
|
CodeMode(
|
|
search_tool_name="tools",
|
|
execute_tool_name="tools",
|
|
sandbox_provider=_UnsafeTestSandboxProvider(),
|
|
)
|