Add ListTools, search limit, and catalog size annotation to CodeMode (#3359)

* Add tests for two-stage pattern, empty full-detail results, empty inputs

* Add ListTools, search limit, catalog size annotation; split tests

Co-authored-by: Claude <noreply@anthropic.com>

* Remove BM25 internal cap so Search.limit is the sole truncation point

* Pass default_limit to BM25 instead of arbitrary high cap

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jeremiah Lowin 2026-03-02 18:00:21 -05:00 committed by GitHub
commit 404b820144
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 704 additions and 264 deletions

View file

@ -30,7 +30,7 @@ You take a normal server with normally registered tools and add a `CodeMode` tra
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms import CodeMode
from fastmcp.experimental.transforms.code_mode import CodeMode
mcp = FastMCP("Server", transforms=[CodeMode()])
@ -114,7 +114,7 @@ This three-stage flow works well for most servers — each step pulls in only th
## Discovery Tools
CodeMode ships with three built-in discovery tools: `Search`, `GetSchemas`, and `GetTags`. By default, only `Search` and `GetSchemas` are enabled. Each tool supports a `default_detail` parameter that sets the default verbosity level, and the LLM can override the detail level on any individual call.
CodeMode ships with four built-in discovery tools: `Search`, `GetSchemas`, `GetTags`, and `ListTools`. By default, only `Search` and `GetSchemas` are enabled. Each tool supports a `default_detail` parameter that sets the default verbosity level, and the LLM can override the detail level on any individual call.
### Detail Levels
@ -132,6 +132,14 @@ CodeMode ships with three built-in discovery tools: `Search`, `GetSchemas`, and
`Search` finds tools by natural-language query using BM25 ranking. At its default `"brief"` detail, results include just tool names and descriptions — enough to decide which tools are worth inspecting further. The LLM can request `"detailed"` to get parameter schemas inline, or `"full"` for the complete JSON.
Search results include an annotation like `"2 of 10 tools:"` when the result set is smaller than the full catalog, so the LLM knows there are more tools to discover with different queries.
You can cap result count with `default_limit`. The LLM can also override the limit per call. This is useful for large catalogs where you want to keep search results focused:
```python
Search(default_limit=5) # return at most 5 results per search
```
If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` parameter so the LLM can narrow results to specific categories before searching.
### GetSchemas
@ -150,6 +158,20 @@ If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` para
`GetTags` isn't included in the defaults — add it when browsing by category would help the LLM orient itself in a large catalog. The LLM can browse tags first, then pass specific tags into Search to narrow results.
### ListTools
`ListTools` dumps the entire catalog at whatever detail level the LLM requests. It supports the same three detail levels as `Search` and `GetSchemas`, defaulting to `"brief"`.
`ListTools` isn't included in the defaults — for large catalogs, search-based discovery is more token-efficient. But for smaller catalogs (under ~20 tools), letting the LLM see everything upfront can be faster than multiple search round-trips:
```python
from fastmcp.experimental.transforms.code_mode import CodeMode, ListTools, GetSchemas
code_mode = CodeMode(
discovery_tools=[ListTools(), GetSchemas()],
)
```
## Discovery Patterns
The right discovery configuration depends on your server — how many tools you have and how complex their parameters are. It may be tempting to minimize round-trips by collapsing everything into fewer steps, but for the complex servers that benefit most from CodeMode, our experience is that staged discovery leads to better results. Flooding the LLM with detailed schemas for tools it doesn't end up using can hurt more than the extra round-trip costs. Each pattern below is a complete, copyable configuration.
@ -160,7 +182,7 @@ The default. The LLM searches for candidates, inspects schemas for the ones it w
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms import CodeMode
from fastmcp.experimental.transforms.code_mode import CodeMode
mcp = FastMCP("Server", transforms=[CodeMode()])
```
@ -169,7 +191,7 @@ If your tools use [tags](/servers/tools#tags), add `GetTags` so the LLM can brow
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms import CodeMode
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import GetTags, Search, GetSchemas
code_mode = CodeMode(
@ -185,7 +207,7 @@ Search returns parameter schemas inline, so the LLM can go straight from search
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms import CodeMode
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import Search, GetSchemas
code_mode = CodeMode(
@ -203,7 +225,7 @@ Skip discovery entirely and bake tool instructions into the execute tool's descr
```python
from fastmcp import FastMCP
from fastmcp.experimental.transforms import CodeMode
from fastmcp.experimental.transforms.code_mode import CodeMode
code_mode = CodeMode(
discovery_tools=[],
@ -225,7 +247,7 @@ Discovery tools are composable — you can mix the built-ins with your own. Each
Here's a minimal example:
```python
from fastmcp.experimental.transforms import CodeMode
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import GetToolCatalog, GetSchemas
from fastmcp.server.context import Context
from fastmcp.tools.tool import Tool
@ -266,7 +288,8 @@ mcp = FastMCP("Server", transforms=[code_mode])
The default `MontySandboxProvider` can enforce execution limits — timeouts, memory caps, recursion depth, and more. Without limits, LLM-generated scripts can run indefinitely.
```python
from fastmcp.experimental.transforms import CodeMode, MontySandboxProvider
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import MontySandboxProvider
sandbox = MontySandboxProvider(
limits={"max_duration_secs": 10, "max_memory": 50_000_000},
@ -293,7 +316,8 @@ You can replace the default sandbox with any object implementing the `SandboxPro
from collections.abc import Callable
from typing import Any
from fastmcp.experimental.transforms import CodeMode, SandboxProvider
from fastmcp.experimental.transforms.code_mode import CodeMode
from fastmcp.experimental.transforms.code_mode import SandboxProvider
class RemoteSandboxProvider:
async def run(