Narrate search example clients (#3321)

* Narrate search example clients for screenshot-readability

* Show actual call_tool invocation in result panels
This commit is contained in:
Jeremiah Lowin 2026-02-27 12:17:31 -05:00 committed by GitHub
commit 2d1fe3ec0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 171 additions and 101 deletions

View file

@ -14,53 +14,8 @@ Both strategies respect the full auth pipeline: middleware, visibility transform
```bash ```bash
# Regex # Regex
uv run python server_regex.py # in one terminal uv run python examples/search/client_regex.py
uv run python client_regex.py # in another
# BM25 # BM25
uv run python server_bm25.py uv run python examples/search/client_bm25.py
uv run python client_bm25.py
``` ```
## Example Output (Regex)
```
=== Available Tools ===
- search_tools: Search for tools matching a regex pattern.
- call_tool: Call a tool by name with the given arguments.
=== Search: math tools (pattern: 'add|multiply|fibonacci') ===
- add: Add two numbers together.
- multiply: Multiply two numbers.
- fibonacci: Generate the first n Fibonacci numbers.
=== Search: text tools (pattern: 'text|string|word') ===
- reverse_string: Reverse a string.
- word_count: Count the number of words in a text.
- to_uppercase: Convert text to uppercase.
=== Calling 'add' via call_tool ===
Result: 42
```
## Example Output (BM25)
```
=== Available Tools ===
- list_files: List files in a directory.
- search_tools: Search for tools using natural language.
- call_tool: Call a tool by name with the given arguments.
=== Search: 'work with numbers' ===
- multiply: Multiply two numbers.
- add: Add two numbers together.
- fibonacci: Generate the first n Fibonacci numbers.
=== Search: 'file operations' ===
- read_file: Read the contents of a file.
=== Calling 'word_count' via call_tool ===
Result: 6
```
Note how `list_files` appears in the BM25 example's tool listing — it's pinned via `always_visible=["list_files"]`, keeping it visible alongside the synthetic search tools.

View file

@ -9,30 +9,55 @@ Run with:
import asyncio import asyncio
import json import json
from typing import Any
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.table import Table from rich.table import Table
from rich.text import Text
from fastmcp.client import Client from fastmcp.client import Client
console = Console() console = Console()
def _get_text(result) -> str: def _get_result(result) -> Any:
"""Extract text content from a CallToolResult.""" """Extract the value from a CallToolResult (structured or text)."""
if result.structured_content is not None:
data = result.structured_content
if isinstance(data, dict) and set(data) == {"result"}:
return data["result"]
return data
return result.content[0].text return result.content[0].text
def _tool_table(tools: list[dict], *, ranked: bool = False) -> Table: def _format_params(tool: dict) -> str:
"""Format inputSchema properties as a compact signature."""
schema = tool.get("inputSchema", {})
props = schema.get("properties", {})
if not props:
return "()"
parts = []
for name, info in props.items():
typ = info.get("type", "")
parts.append(f"{name}: {typ}" if typ else name)
return f"({', '.join(parts)})"
def _tool_table(
tools: list[dict], *, ranked: bool = False, show_params: bool = False
) -> Table:
table = Table(show_header=True, show_edge=False, pad_edge=False, expand=True) table = Table(show_header=True, show_edge=False, pad_edge=False, expand=True)
if ranked: if ranked:
table.add_column("#", style="dim", width=3, justify="right") table.add_column("#", style="dim", width=3, justify="right")
table.add_column("Tool", style="cyan", no_wrap=True) table.add_column("Tool", style="cyan", no_wrap=True)
if show_params:
table.add_column("Parameters", style="dim", no_wrap=True)
table.add_column("Description", style="dim") table.add_column("Description", style="dim")
for i, tool in enumerate(tools, 1): for i, tool in enumerate(tools, 1):
row = [tool["name"], tool.get("description", "")] row = [tool["name"]]
if show_params:
row.append(_format_params(tool))
row.append(tool.get("description", ""))
if ranked: if ranked:
row.insert(0, str(i)) row.insert(0, str(i))
table.add_row(*row) table.add_row(*row)
@ -45,35 +70,66 @@ async def main():
console.rule("[bold]BM25 Search Transform[/bold]") console.rule("[bold]BM25 Search Transform[/bold]")
console.print() console.print()
# list_files is pinned via always_visible # Step 1: list_tools shows only synthetic tools + pinned tools
console.print(
"The server has 8 tools. BM25SearchTransform replaces them with "
"just [bold]search_tools[/bold] and [bold]call_tool[/bold]. "
"[bold]list_files[/bold] stays visible via [dim]always_visible[/dim]:"
)
console.print()
tools = await client.list_tools() tools = await client.list_tools()
visible = [{"name": t.name, "description": t.description} for t in tools] visible = [{"name": t.name, "description": t.description} for t in tools]
console.print( console.print(
Panel( Panel(
_tool_table(visible), _tool_table(visible),
title="[bold]list_tools()[/bold]", title="[bold]list_tools()[/bold]",
subtitle="[dim]list_files pinned via always_visible[/dim]", title_align="left",
border_style="blue", border_style="blue",
) )
) )
console.print() console.print()
# Natural language searches — BM25 ranks by relevance # Step 2: natural language search discovers tools by relevance
queries = ["work with numbers", "manipulate text strings", "file operations"] console.print(
for query in queries: "The LLM uses [bold]search_tools[/bold] with natural language "
result = await client.call_tool("search_tools", {"query": query}) "to discover tools ranked by relevance:"
found = json.loads(_get_text(result)) )
console.print( console.print()
Panel( result = await client.call_tool("search_tools", {"query": "work with numbers"})
_tool_table(found, ranked=True), found = _get_result(result)
title=f'[bold]search_tools[/bold][dim](query="{query}")[/dim]', if isinstance(found, str):
subtitle=f"[dim]{len(found)} result{'s' if len(found) != 1 else ''}[/dim]", found = json.loads(found)
border_style="green", console.print(
) Panel(
_tool_table(found, ranked=True, show_params=True),
title='[bold]search_tools[/bold] [dim]query="work with numbers"[/dim]',
title_align="left",
border_style="green",
) )
console.print() )
console.print()
# Call a discovered tool result = await client.call_tool(
"search_tools", {"query": "manipulate text strings"}
)
found = _get_result(result)
if isinstance(found, str):
found = json.loads(found)
console.print(
Panel(
_tool_table(found, ranked=True, show_params=True),
title='[bold]search_tools[/bold] [dim]query="manipulate text strings"[/dim]',
title_align="left",
border_style="green",
)
)
console.print()
# Step 3: call a discovered tool
console.print(
"Then the LLM calls a discovered tool through [bold]call_tool[/bold]:"
)
console.print()
result = await client.call_tool( result = await client.call_tool(
"call_tool", "call_tool",
{ {
@ -81,11 +137,14 @@ async def main():
"arguments": {"text": "BM25 search makes tool discovery easy"}, "arguments": {"text": "BM25 search makes tool discovery easy"},
}, },
) )
call_label = Text.assemble( console.print(
("call_tool", "bold"), Panel(
('(word_count, text="BM25 search makes tool discovery easy")', "dim"), f'call_tool(name="word_count", arguments={{"text": "BM25 search makes tool discovery easy"}})\n→ [bold green]{_get_result(result)}[/bold green]',
title="[bold]call_tool()[/bold]",
title_align="left",
border_style="magenta",
)
) )
console.print(call_label, "", f"[bold green]{_get_text(result)}[/bold green]")
console.print() console.print()

View file

@ -1,7 +1,7 @@
"""Example: Client using regex search to discover and call tools. """Example: Client using regex search to discover and call tools.
Demonstrates the workflow: list tools (sees only search_tools + call_tool), Regex search lets clients find tools by matching patterns against tool names
search for tools matching a regex pattern, then call a discovered tool. and descriptions. Precise when you know what you're looking for.
Run with: Run with:
uv run python examples/search/client_regex.py uv run python examples/search/client_regex.py
@ -9,28 +9,58 @@ Run with:
import asyncio import asyncio
import json import json
from typing import Any
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.table import Table from rich.table import Table
from rich.text import Text
from fastmcp.client import Client from fastmcp.client import Client
console = Console() console = Console()
def _get_text(result) -> str: def _get_result(result) -> Any:
"""Extract text content from a CallToolResult.""" """Extract the value from a CallToolResult (structured or text)."""
if result.structured_content is not None:
data = result.structured_content
if isinstance(data, dict) and set(data) == {"result"}:
return data["result"]
return data
return result.content[0].text return result.content[0].text
def _tool_table(tools: list[dict]) -> Table: def _format_params(tool: dict) -> str:
"""Format inputSchema properties as a compact signature."""
schema = tool.get("inputSchema", {})
props = schema.get("properties", {})
if not props:
return "()"
parts = []
for name, info in props.items():
typ = info.get("type", "")
parts.append(f"{name}: {typ}" if typ else name)
return f"({', '.join(parts)})"
def _tool_table(
tools: list[dict], *, ranked: bool = False, show_params: bool = False
) -> Table:
table = Table(show_header=True, show_edge=False, pad_edge=False, expand=True) table = Table(show_header=True, show_edge=False, pad_edge=False, expand=True)
if ranked:
table.add_column("#", style="dim", width=3, justify="right")
table.add_column("Tool", style="cyan", no_wrap=True) table.add_column("Tool", style="cyan", no_wrap=True)
if show_params:
table.add_column("Parameters", style="dim", no_wrap=True)
table.add_column("Description", style="dim") table.add_column("Description", style="dim")
for tool in tools: for i, tool in enumerate(tools, 1):
table.add_row(tool["name"], tool.get("description", "")) row = [tool["name"]]
if show_params:
row.append(_format_params(tool))
row.append(tool.get("description", ""))
if ranked:
row.insert(0, str(i))
table.add_row(*row)
return table return table
@ -40,64 +70,90 @@ async def main():
console.rule("[bold]Regex Search Transform[/bold]") console.rule("[bold]Regex Search Transform[/bold]")
console.print() console.print()
# Show what the client actually sees # Step 1: list_tools shows only synthetic tools
console.print(
"The server has 6 tools. RegexSearchTransform replaces them with "
"just [bold]search_tools[/bold] and [bold]call_tool[/bold]:"
)
console.print()
tools = await client.list_tools() tools = await client.list_tools()
visible = [{"name": t.name, "description": t.description} for t in tools] visible = [{"name": t.name, "description": t.description} for t in tools]
console.print( console.print(
Panel( Panel(
_tool_table(visible), _tool_table(visible),
title="[bold]list_tools()[/bold]", title="[bold]list_tools()[/bold]",
subtitle="[dim]real tools are discoverable via search[/dim]", title_align="left",
border_style="blue", border_style="blue",
) )
) )
console.print() console.print()
# Search for math tools # Step 2: regex patterns discover tools
console.print(
"The LLM uses [bold]search_tools[/bold] with regex patterns "
"to find tools by name:"
)
console.print()
result = await client.call_tool( result = await client.call_tool(
"search_tools", {"pattern": "add|multiply|fibonacci"} "search_tools", {"pattern": "add|multiply|fibonacci"}
) )
found = json.loads(_get_text(result)) found = _get_result(result)
if isinstance(found, str):
found = json.loads(found)
console.print( console.print(
Panel( Panel(
_tool_table(found), _tool_table(found, show_params=True),
title='[bold]search_tools[/bold][dim](pattern="add|multiply|fibonacci")[/dim]', title='[bold]search_tools[/bold] [dim]pattern="add|multiply|fibonacci"[/dim]',
title_align="left",
border_style="green", border_style="green",
) )
) )
console.print() console.print()
# Search for text tools
result = await client.call_tool("search_tools", {"pattern": "text|string|word"}) result = await client.call_tool("search_tools", {"pattern": "text|string|word"})
found = json.loads(_get_text(result)) found = _get_result(result)
if isinstance(found, str):
found = json.loads(found)
console.print( console.print(
Panel( Panel(
_tool_table(found), _tool_table(found, show_params=True),
title='[bold]search_tools[/bold][dim](pattern="text|string|word")[/dim]', title='[bold]search_tools[/bold] [dim]pattern="text|string|word"[/dim]',
title_align="left",
border_style="green", border_style="green",
) )
) )
console.print() console.print()
# Call discovered tools via the proxy # Step 3: call discovered tools
console.print(
"Then the LLM calls discovered tools through [bold]call_tool[/bold]:"
)
console.print()
result = await client.call_tool( result = await client.call_tool(
"call_tool", {"name": "add", "arguments": {"a": 17, "b": 25}} "call_tool", {"name": "add", "arguments": {"a": 17, "b": 25}}
) )
call_label = Text.assemble( console.print(
("call_tool", "bold"), Panel(
("(add, a=17, b=25)", "dim"), f'call_tool(name="add", arguments={{"a": 17, "b": 25}})\n→ [bold green]{_get_result(result)}[/bold green]',
title="[bold]call_tool()[/bold]",
title_align="left",
border_style="magenta",
)
) )
console.print(call_label, "", f"[bold green]{_get_text(result)}[/bold green]") console.print()
result = await client.call_tool( result = await client.call_tool(
"call_tool", "call_tool",
{"name": "reverse_string", "arguments": {"text": "hello world"}}, {"name": "reverse_string", "arguments": {"text": "hello world"}},
) )
call_label = Text.assemble( console.print(
("call_tool", "bold"), Panel(
('(reverse_string, text="hello world")', "dim"), f'call_tool(name="reverse_string", arguments={{"text": "hello world"}})\n→ [bold green]{_get_result(result)}[/bold green]',
title="[bold]call_tool()[/bold]",
title_align="left",
border_style="magenta",
)
) )
console.print(call_label, "", f"[bold green]{_get_text(result)}[/bold green]")
console.print() console.print()

View file

@ -12,6 +12,8 @@ Run with:
uv run python examples/search/server_bm25.py uv run python examples/search/server_bm25.py
""" """
import os
from fastmcp import FastMCP from fastmcp import FastMCP
from fastmcp.server.transforms.search import BM25SearchTransform from fastmcp.server.transforms.search import BM25SearchTransform
@ -62,8 +64,6 @@ def to_uppercase(text: str) -> str:
@mcp.tool @mcp.tool
def list_files(directory: str) -> list[str]: def list_files(directory: str) -> list[str]:
"""List files in a directory.""" """List files in a directory."""
import os
return os.listdir(directory) return os.listdir(directory)