From 2d1fe3ec0fe00e0b567a392c6e6043e132dc8c0a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:17:31 -0500 Subject: [PATCH] Narrate search example clients (#3321) * Narrate search example clients for screenshot-readability * Show actual call_tool invocation in result panels --- examples/search/README.md | 49 +------------- examples/search/client_bm25.py | 109 +++++++++++++++++++++++-------- examples/search/client_regex.py | 110 ++++++++++++++++++++++++-------- examples/search/server_bm25.py | 4 +- 4 files changed, 171 insertions(+), 101 deletions(-) diff --git a/examples/search/README.md b/examples/search/README.md index 253b196c6..32a26390d 100644 --- a/examples/search/README.md +++ b/examples/search/README.md @@ -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. diff --git a/examples/search/client_bm25.py b/examples/search/client_bm25.py index a1b67cbda..b33e395a3 100644 --- a/examples/search/client_bm25.py +++ b/examples/search/client_bm25.py @@ -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() diff --git a/examples/search/client_regex.py b/examples/search/client_regex.py index 6bf17de63..ccccefbd2 100644 --- a/examples/search/client_regex.py +++ b/examples/search/client_regex.py @@ -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() diff --git a/examples/search/server_bm25.py b/examples/search/server_bm25.py index 9491d185f..63cdf8048 100644 --- a/examples/search/server_bm25.py +++ b/examples/search/server_bm25.py @@ -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)