mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
b9153404f4
commit
2d1fe3ec0f
4 changed files with 171 additions and 101 deletions
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue