Add search_result_serializer hook and serialize_tools_for_output_markdown (#3337)

This commit is contained in:
Magnus 2026-03-01 20:20:24 +01:00 committed by GitHub
commit 1e72f2457b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 490 additions and 11 deletions

View file

@ -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:

View file

@ -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",
]

View file

@ -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",
]

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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)

View file

@ -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)

View file

@ -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"