mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 05:54:19 +02:00
Add search_result_serializer hook and serialize_tools_for_output_markdown (#3337)
This commit is contained in:
parent
9d635bc026
commit
1e72f2457b
7 changed files with 490 additions and 11 deletions
|
|
@ -72,6 +72,55 @@ mcp = FastMCP(
|
|||
)
|
||||
```
|
||||
|
||||
### Search Result Format
|
||||
|
||||
By default, `search` returns tool definitions in the same JSON format as `list_tools` — full `inputSchema`, `outputSchema`, and metadata. This is complete, but verbose: each tool definition carries significant structural overhead that the LLM doesn't need just to decide which tool to call next.
|
||||
|
||||
`serialize_tools_for_output_markdown` is a built-in alternative that renders the same information as compact markdown. In benchmarks across typical tool catalogs it reduces search result tokens by ~65-70%.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.experimental.transforms import CodeMode
|
||||
from fastmcp.server.transforms.search import serialize_tools_for_output_markdown
|
||||
|
||||
mcp = FastMCP(
|
||||
"Server",
|
||||
transforms=[CodeMode(search_result_serializer=serialize_tools_for_output_markdown)],
|
||||
)
|
||||
```
|
||||
|
||||
For a tool like `create_document`, the output looks like:
|
||||
|
||||
```
|
||||
### create_document
|
||||
|
||||
Create a new document in the workspace with the given metadata.
|
||||
|
||||
**Parameters**
|
||||
- `title` (string, required)
|
||||
- `content` (string, required)
|
||||
- `tags` (string[], required)
|
||||
- `author` (string, required)
|
||||
- `published` (boolean)
|
||||
- `parent_id` (string?)
|
||||
```
|
||||
|
||||
You can also supply any callable that takes a sequence of tools and returns a string or a list of dicts. Async callables are supported too:
|
||||
|
||||
```python
|
||||
from fastmcp.server.transforms.search import SearchResultSerializer
|
||||
|
||||
def my_serializer(tools) -> str:
|
||||
return "\n".join(f"{t.name}: {t.description}" for t in tools)
|
||||
|
||||
mcp = FastMCP(
|
||||
"Server",
|
||||
transforms=[CodeMode(search_result_serializer=my_serializer)],
|
||||
)
|
||||
```
|
||||
|
||||
The default JSON format is unchanged when `search_result_serializer` is not set — this is purely opt-in.
|
||||
|
||||
## Execute
|
||||
|
||||
The `execute` meta-tool takes a `code` string containing async Python. Inside the sandbox, one function is available:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ from fastmcp.server.transforms import GetToolNext
|
|||
from fastmcp.server.transforms.catalog import CatalogTransform
|
||||
from fastmcp.server.transforms.search.base import (
|
||||
BaseSearchTransform,
|
||||
_serialize_tools_for_output,
|
||||
SearchResultSerializer,
|
||||
_invoke_serializer,
|
||||
serialize_tools_for_output_json,
|
||||
serialize_tools_for_output_markdown,
|
||||
)
|
||||
from fastmcp.tools.tool import Tool, ToolResult
|
||||
from fastmcp.utilities.versions import VersionSpec
|
||||
|
|
@ -131,6 +134,7 @@ class CodeMode(CatalogTransform):
|
|||
search_tool_name: str = "search",
|
||||
execute_tool_name: str = "execute",
|
||||
execute_description: str | None = None,
|
||||
search_result_serializer: SearchResultSerializer | None = None,
|
||||
) -> None:
|
||||
if search_tool_name == execute_tool_name:
|
||||
raise ValueError(
|
||||
|
|
@ -145,6 +149,9 @@ class CodeMode(CatalogTransform):
|
|||
self.sandbox_provider = sandbox_provider or MontySandboxProvider()
|
||||
self._cached_search_tool: Tool | None = None
|
||||
self._cached_execute_tool: Tool | None = None
|
||||
self._search_result_serializer: SearchResultSerializer | None = (
|
||||
search_result_serializer
|
||||
)
|
||||
|
||||
if search_transform is None:
|
||||
from fastmcp.server.transforms.search.bm25 import BM25SearchTransform
|
||||
|
|
@ -205,7 +212,7 @@ class CodeMode(CatalogTransform):
|
|||
"Search query to find available tools",
|
||||
],
|
||||
ctx: Context = None, # type: ignore[assignment]
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Search for available tools by query.
|
||||
|
||||
Returns matching tool definitions ranked by relevance,
|
||||
|
|
@ -213,7 +220,11 @@ class CodeMode(CatalogTransform):
|
|||
"""
|
||||
tools = await transform.get_tool_catalog(ctx)
|
||||
results = await transform._search_transform._search(tools, query)
|
||||
return _serialize_tools_for_output(results)
|
||||
if transform._search_result_serializer is not None:
|
||||
return await _invoke_serializer(
|
||||
transform._search_result_serializer, results
|
||||
)
|
||||
return await transform._search_transform._render_results(results)
|
||||
|
||||
return Tool.from_function(fn=search, name=self.search_tool_name)
|
||||
|
||||
|
|
@ -287,4 +298,7 @@ __all__ = [
|
|||
"CodeMode",
|
||||
"MontySandboxProvider",
|
||||
"SandboxProvider",
|
||||
"SearchResultSerializer",
|
||||
"serialize_tools_for_output_json",
|
||||
"serialize_tools_for_output_markdown",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,10 +14,18 @@ Example:
|
|||
```
|
||||
"""
|
||||
|
||||
from fastmcp.server.transforms.search.base import (
|
||||
SearchResultSerializer,
|
||||
serialize_tools_for_output_json,
|
||||
serialize_tools_for_output_markdown,
|
||||
)
|
||||
from fastmcp.server.transforms.search.bm25 import BM25SearchTransform
|
||||
from fastmcp.server.transforms.search.regex import RegexSearchTransform
|
||||
|
||||
__all__ = [
|
||||
"BM25SearchTransform",
|
||||
"RegexSearchTransform",
|
||||
"SearchResultSerializer",
|
||||
"serialize_tools_for_output_json",
|
||||
"serialize_tools_for_output_markdown",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ Example::
|
|||
"""
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastmcp.server.context import Context
|
||||
|
|
@ -57,13 +57,100 @@ def _extract_searchable_text(tool: Tool) -> str:
|
|||
return " ".join(parts)
|
||||
|
||||
|
||||
def _serialize_tools_for_output(tools: Sequence[Tool]) -> list[dict[str, Any]]:
|
||||
def serialize_tools_for_output_json(tools: Sequence[Tool]) -> list[dict[str, Any]]:
|
||||
"""Serialize tools to the same dict format as ``list_tools`` output."""
|
||||
return [
|
||||
tool.to_mcp_tool().model_dump(mode="json", exclude_none=True) for tool in tools
|
||||
]
|
||||
|
||||
|
||||
SearchResultSerializer = Callable[[Sequence[Tool]], Any | Awaitable[Any]]
|
||||
|
||||
|
||||
async def _invoke_serializer(
|
||||
serializer: SearchResultSerializer, tools: Sequence[Tool]
|
||||
) -> Any:
|
||||
"""Call a serializer and await the result if it returns a coroutine."""
|
||||
result = serializer(tools)
|
||||
if isinstance(result, Awaitable):
|
||||
return await result
|
||||
return result
|
||||
|
||||
|
||||
def _union_type(branches: list[Any]) -> str:
|
||||
branch_types = list(dict.fromkeys(_schema_type(b) for b in branches))
|
||||
if "null" not in branch_types:
|
||||
return " | ".join(branch_types) if branch_types else "any"
|
||||
non_null = [b for b in branch_types if b != "null"]
|
||||
if not non_null:
|
||||
return "null"
|
||||
return f"{' | '.join(non_null)}?"
|
||||
|
||||
|
||||
def _schema_type(schema: Any) -> str:
|
||||
# Intentionally heuristic: the goal is a concise readable label, not a
|
||||
# complete type system. Malformed schemas (e.g. {"type": ""}) → "any".
|
||||
if not isinstance(schema, dict):
|
||||
return "any"
|
||||
t = schema.get("type")
|
||||
if isinstance(t, str) and t:
|
||||
if t == "array":
|
||||
return f"{_schema_type(schema.get('items'))}[]"
|
||||
if t == "null":
|
||||
return "null"
|
||||
return t
|
||||
if "$ref" in schema:
|
||||
return "object"
|
||||
if "anyOf" in schema:
|
||||
return _union_type(schema["anyOf"])
|
||||
if "oneOf" in schema:
|
||||
return _union_type(schema["oneOf"])
|
||||
if "allOf" in schema:
|
||||
# allOf = intersection / Pydantic composed model → always an object
|
||||
return "object"
|
||||
return "object" if "properties" in schema else "any"
|
||||
|
||||
|
||||
def _schema_section(schema: dict[str, Any] | None, title: str) -> list[str]:
|
||||
lines = [f"**{title}**"]
|
||||
if not isinstance(schema, dict):
|
||||
lines.append("- `value` (any)")
|
||||
return lines
|
||||
|
||||
props = schema.get("properties")
|
||||
raw_required = schema.get("required")
|
||||
req = set(raw_required) if isinstance(raw_required, list) else set()
|
||||
if props is None:
|
||||
# Not a properties-based schema — treat as a single unnamed value.
|
||||
lines.append(f"- `value` ({_schema_type(schema)})")
|
||||
return lines
|
||||
if not props:
|
||||
# Object schema with no properties — zero-argument tool.
|
||||
lines.append("*(no parameters)*")
|
||||
return lines
|
||||
|
||||
for name, field in props.items():
|
||||
required = ", required" if name in req else ""
|
||||
lines.append(f"- `{name}` ({_schema_type(field)}{required})")
|
||||
return lines
|
||||
|
||||
|
||||
def serialize_tools_for_output_markdown(tools: Sequence[Tool]) -> str:
|
||||
"""Serialize tools to compact markdown, using ~65-70% fewer tokens than JSON."""
|
||||
if not tools:
|
||||
return "No tools matched the query."
|
||||
blocks: list[str] = []
|
||||
for tool in tools:
|
||||
lines = [f"### {tool.name}"]
|
||||
if tool.description:
|
||||
lines.extend(["", tool.description.strip()])
|
||||
lines.extend(["", *_schema_section(tool.parameters, "Parameters")])
|
||||
if tool.output_schema is not None:
|
||||
lines.extend(["", *_schema_section(tool.output_schema, "Returns")])
|
||||
blocks.append("\n".join(lines))
|
||||
return "\n\n".join(blocks)
|
||||
|
||||
|
||||
class BaseSearchTransform(CatalogTransform):
|
||||
"""Replace the tool listing with a search interface.
|
||||
|
||||
|
|
@ -94,12 +181,16 @@ class BaseSearchTransform(CatalogTransform):
|
|||
always_visible: list[str] | None = None,
|
||||
search_tool_name: str = "search_tools",
|
||||
call_tool_name: str = "call_tool",
|
||||
search_result_serializer: SearchResultSerializer | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._max_results = max_results
|
||||
self._always_visible = set(always_visible or [])
|
||||
self._search_tool_name = search_tool_name
|
||||
self._call_tool_name = call_tool_name
|
||||
self._search_result_serializer: SearchResultSerializer = (
|
||||
search_result_serializer or serialize_tools_for_output_json
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Transform interface
|
||||
|
|
@ -152,6 +243,13 @@ class BaseSearchTransform(CatalogTransform):
|
|||
|
||||
return Tool.from_function(fn=call_tool, name=self._call_tool_name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialization
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _render_results(self, tools: Sequence[Tool]) -> Any:
|
||||
return await _invoke_serializer(self._search_result_serializer, tools)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Catalog access
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ from typing import Annotated, Any
|
|||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.transforms.search.base import (
|
||||
BaseSearchTransform,
|
||||
SearchResultSerializer,
|
||||
_extract_searchable_text,
|
||||
_serialize_tools_for_output,
|
||||
)
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
|
|
@ -97,12 +97,14 @@ class BM25SearchTransform(BaseSearchTransform):
|
|||
always_visible: list[str] | None = None,
|
||||
search_tool_name: str = "search_tools",
|
||||
call_tool_name: str = "call_tool",
|
||||
search_result_serializer: SearchResultSerializer | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
max_results=max_results,
|
||||
always_visible=always_visible,
|
||||
search_tool_name=search_tool_name,
|
||||
call_tool_name=call_tool_name,
|
||||
search_result_serializer=search_result_serializer,
|
||||
)
|
||||
self._index = _BM25Index()
|
||||
self._indexed_tools: Sequence[Tool] = ()
|
||||
|
|
@ -114,7 +116,7 @@ class BM25SearchTransform(BaseSearchTransform):
|
|||
async def search_tools(
|
||||
query: Annotated[str, "Natural language query to search for tools"],
|
||||
ctx: Context = None, # type: ignore[assignment]
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Search for tools using natural language.
|
||||
|
||||
Returns matching tool definitions ranked by relevance,
|
||||
|
|
@ -122,7 +124,7 @@ class BM25SearchTransform(BaseSearchTransform):
|
|||
"""
|
||||
hidden = await transform._get_visible_tools(ctx)
|
||||
results = await transform._search(hidden, query)
|
||||
return _serialize_tools_for_output(results)
|
||||
return await transform._render_results(results)
|
||||
|
||||
return Tool.from_function(fn=search_tools, name=self._search_tool_name)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from fastmcp.server.context import Context
|
|||
from fastmcp.server.transforms.search.base import (
|
||||
BaseSearchTransform,
|
||||
_extract_searchable_text,
|
||||
_serialize_tools_for_output,
|
||||
)
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
|
|
@ -29,14 +28,14 @@ class RegexSearchTransform(BaseSearchTransform):
|
|||
"Regex pattern to match against tool names, descriptions, and parameters",
|
||||
],
|
||||
ctx: Context = None, # type: ignore[assignment]
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Search for tools matching a regex pattern.
|
||||
|
||||
Returns matching tool definitions in the same format as list_tools.
|
||||
"""
|
||||
hidden = await transform._get_visible_tools(ctx)
|
||||
results = await transform._search(hidden, pattern)
|
||||
return _serialize_tools_for_output(results)
|
||||
return await transform._render_results(results)
|
||||
|
||||
return Tool.from_function(fn=search_tools, name=self._search_tool_name)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ 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.server.transforms.search.base import (
|
||||
_schema_section,
|
||||
_schema_type,
|
||||
serialize_tools_for_output_markdown,
|
||||
)
|
||||
from fastmcp.tools.tool import ToolResult
|
||||
|
||||
|
||||
|
|
@ -469,3 +474,307 @@ def test_code_mode_rejects_identical_tool_names() -> None:
|
|||
execute_tool_name="tools",
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _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: optional field — null stripped, "?" appended
|
||||
({"anyOf": [{"type": "string"}, {"type": "null"}]}, "string?"),
|
||||
# anyOf: non-nullable union — all branches kept
|
||||
({"anyOf": [{"type": "string"}, {"type": "integer"}]}, "string | integer"),
|
||||
# anyOf: multiple branches including null
|
||||
(
|
||||
{"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]},
|
||||
"string | integer?",
|
||||
),
|
||||
# anyOf: all-null (edge case)
|
||||
({"anyOf": [{"type": "null"}]}, "null"),
|
||||
# anyOf: empty
|
||||
({"anyOf": []}, "any"),
|
||||
# oneOf: treated identically to anyOf
|
||||
({"oneOf": [{"type": "string"}, {"type": "null"}]}, "string?"),
|
||||
({"oneOf": [{"type": "string"}, {"type": "integer"}]}, "string | integer"),
|
||||
# allOf: always "object" (Pydantic composite / intersection type)
|
||||
({"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 → generic fallback
|
||||
(None, ["**Parameters**", "- `value` (any)"]),
|
||||
# Non-dict → generic fallback
|
||||
("string", ["**Parameters**", "- `value` (any)"]),
|
||||
# Scalar schema without properties → type label used
|
||||
({"type": "string"}, ["**Parameters**", "- `value` (string)"]),
|
||||
# Empty properties dict → zero-argument tool
|
||||
(
|
||||
{"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
|
||||
):
|
||||
"""Tools without output_schema should not render a Returns section."""
|
||||
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)
|
||||
|
||||
# Header present, no extra blank description line injected
|
||||
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
|
||||
# Tools separated by double newline
|
||||
assert "\n\n" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CodeMode search_result_serializer integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _unwrap_serializer_result(result: ToolResult) -> str:
|
||||
"""Extract a string result returned by a custom search serializer.
|
||||
|
||||
Custom serializers returning str are wrapped in {"result": "..."} by the
|
||||
output schema, so we need one extra level of unwrapping compared to
|
||||
_unwrap_result.
|
||||
"""
|
||||
data = _unwrap_result(result)
|
||||
if isinstance(data, dict) and "result" in data:
|
||||
return data["result"]
|
||||
assert isinstance(data, str)
|
||||
return data
|
||||
|
||||
|
||||
async def test_code_mode_search_supports_custom_serializer() -> None:
|
||||
mcp = FastMCP("CodeMode Custom Serializer")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
return x * x
|
||||
|
||||
mcp.add_transform(
|
||||
CodeMode(
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
search_result_serializer=lambda tools: "\n".join(t.name for t in tools),
|
||||
)
|
||||
)
|
||||
|
||||
result = await _run_tool(mcp, "search", {"query": "square"})
|
||||
text = _unwrap_serializer_result(result)
|
||||
assert isinstance(text, str)
|
||||
assert "square" in text
|
||||
|
||||
|
||||
async def test_code_mode_search_supports_async_custom_serializer() -> None:
|
||||
mcp = FastMCP("CodeMode Async Serializer")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
return x * x
|
||||
|
||||
async def async_serializer(tools: Any) -> str:
|
||||
return ", ".join(t.name for t in tools)
|
||||
|
||||
mcp.add_transform(
|
||||
CodeMode(
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
search_result_serializer=async_serializer,
|
||||
)
|
||||
)
|
||||
|
||||
result = await _run_tool(mcp, "search", {"query": "square"})
|
||||
text = _unwrap_serializer_result(result)
|
||||
assert isinstance(text, str)
|
||||
assert "square" in text
|
||||
|
||||
|
||||
async def test_code_mode_search_markdown_serializer() -> None:
|
||||
mcp = FastMCP("CodeMode Markdown Serializer")
|
||||
|
||||
@mcp.tool
|
||||
def square(x: int) -> int:
|
||||
"""Compute the square of a number."""
|
||||
return x * x
|
||||
|
||||
mcp.add_transform(
|
||||
CodeMode(
|
||||
sandbox_provider=_UnsafeTestSandboxProvider(),
|
||||
search_result_serializer=serialize_tools_for_output_markdown,
|
||||
)
|
||||
)
|
||||
|
||||
result = await _run_tool(mcp, "search", {"query": "square"})
|
||||
text = _unwrap_serializer_result(result)
|
||||
assert isinstance(text, str)
|
||||
assert "### square" in text
|
||||
assert "Compute the square of a number." in text
|
||||
assert "**Parameters**" in text
|
||||
|
||||
|
||||
async def test_code_mode_search_default_serializer_returns_list() -> None:
|
||||
"""Default (no custom serializer) still returns the JSON list format."""
|
||||
mcp = FastMCP("CodeMode Default Serializer")
|
||||
|
||||
@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 isinstance(tools, list)
|
||||
assert tools[0]["name"] == "square"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue