fastmcp/tests/experimental/transforms/test_code_mode.py
Adam Azzam b9153404f4
Add experimental CodeMode transform (#3297)
* 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>
2026-02-27 12:14:03 -05:00

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(),
)