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
# Regex
uv run python server_regex.py # in one terminal
uv run python client_regex.py # in another
uv run python examples/search/client_regex.py
# BM25
uv run python server_bm25.py
uv run python client_bm25.py
uv run python examples/search/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 json
from typing import Any
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from fastmcp.client import Client
console = Console()
def _get_text(result) -> str:
"""Extract text content from a CallToolResult."""
def _get_result(result) -> Any:
"""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
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)
if ranked:
table.add_column("#", style="dim", width=3, justify="right")
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")
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:
row.insert(0, str(i))
table.add_row(*row)
@ -45,35 +70,66 @@ async def main():
console.rule("[bold]BM25 Search Transform[/bold]")
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()
visible = [{"name": t.name, "description": t.description} for t in tools]
console.print(
Panel(
_tool_table(visible),
title="[bold]list_tools()[/bold]",
subtitle="[dim]list_files pinned via always_visible[/dim]",
title_align="left",
border_style="blue",
)
)
console.print()
# Natural language searches — BM25 ranks by relevance
queries = ["work with numbers", "manipulate text strings", "file operations"]
for query in queries:
result = await client.call_tool("search_tools", {"query": query})
found = json.loads(_get_text(result))
console.print(
Panel(
_tool_table(found, ranked=True),
title=f'[bold]search_tools[/bold][dim](query="{query}")[/dim]',
subtitle=f"[dim]{len(found)} result{'s' if len(found) != 1 else ''}[/dim]",
border_style="green",
)
# Step 2: natural language search discovers tools by relevance
console.print(
"The LLM uses [bold]search_tools[/bold] with natural language "
"to discover tools ranked by relevance:"
)
console.print()
result = await client.call_tool("search_tools", {"query": "work with numbers"})
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="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(
"call_tool",
{
@ -81,11 +137,14 @@ async def main():
"arguments": {"text": "BM25 search makes tool discovery easy"},
},
)
call_label = Text.assemble(
("call_tool", "bold"),
('(word_count, text="BM25 search makes tool discovery easy")', "dim"),
console.print(
Panel(
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()

View file

@ -1,7 +1,7 @@
"""Example: Client using regex search to discover and call tools.
Demonstrates the workflow: list tools (sees only search_tools + call_tool),
search for tools matching a regex pattern, then call a discovered tool.
Regex search lets clients find tools by matching patterns against tool names
and descriptions. Precise when you know what you're looking for.
Run with:
uv run python examples/search/client_regex.py
@ -9,28 +9,58 @@ Run with:
import asyncio
import json
from typing import Any
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from fastmcp.client import Client
console = Console()
def _get_text(result) -> str:
"""Extract text content from a CallToolResult."""
def _get_result(result) -> Any:
"""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
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)
if ranked:
table.add_column("#", style="dim", width=3, justify="right")
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")
for tool in tools:
table.add_row(tool["name"], tool.get("description", ""))
for i, tool in enumerate(tools, 1):
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
@ -40,64 +70,90 @@ async def main():
console.rule("[bold]Regex Search Transform[/bold]")
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()
visible = [{"name": t.name, "description": t.description} for t in tools]
console.print(
Panel(
_tool_table(visible),
title="[bold]list_tools()[/bold]",
subtitle="[dim]real tools are discoverable via search[/dim]",
title_align="left",
border_style="blue",
)
)
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(
"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(
Panel(
_tool_table(found),
title='[bold]search_tools[/bold][dim](pattern="add|multiply|fibonacci")[/dim]',
_tool_table(found, show_params=True),
title='[bold]search_tools[/bold] [dim]pattern="add|multiply|fibonacci"[/dim]',
title_align="left",
border_style="green",
)
)
console.print()
# Search for text tools
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(
Panel(
_tool_table(found),
title='[bold]search_tools[/bold][dim](pattern="text|string|word")[/dim]',
_tool_table(found, show_params=True),
title='[bold]search_tools[/bold] [dim]pattern="text|string|word"[/dim]',
title_align="left",
border_style="green",
)
)
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(
"call_tool", {"name": "add", "arguments": {"a": 17, "b": 25}}
)
call_label = Text.assemble(
("call_tool", "bold"),
("(add, a=17, b=25)", "dim"),
console.print(
Panel(
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(
"call_tool",
{"name": "reverse_string", "arguments": {"text": "hello world"}},
)
call_label = Text.assemble(
("call_tool", "bold"),
('(reverse_string, text="hello world")', "dim"),
console.print(
Panel(
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()

View file

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