Tools, Resources, and Prompts
Advanced Tool Patterns
The patterns below all answer the same question in different ways: how much work should happen inside one tool call, and how much should be left to the model to orchestrate?
Tool Composition
Two tools that are always used together are often better as one. Each round trip costs a turn, and every turn is a chance for the model to lose the thread:
Two granular tools vs one composed tool
Composition is not free. A composed tool hides its intermediate steps, so when the summary is wrong you cannot tell whether the search or the summariser failed. The rule of thumb: compose when the intermediate result is never independently useful, and keep them separate when the model might legitimately want to stop halfway.
@mcp.tool()
async def search_and_summarize(query: str, max_results: int = 5) -> str:
"""Search documents and return a summary of the best matches."""
results = await search_documents(query)
return await generate_summary(results[:max_results])
The default lives in the signature, so it appears in the generated schema and applies
when the model omits the argument. Writing the default twice — once in the schema, once in
a .get(..., 5) call — is how the two drift apart.
Reporting Progress on Long Operations
A tool call returns exactly one result. You cannot yield partial results from a tool —
an async generator is not a valid return value and the call fails validation. What you can
do is send progress notifications while the single call is still open:
from mcp.server.mcpserver import Context
@mcp.tool()
async def analyze_large_dataset(data: str, ctx: Context) -> str:
"""Analyze a dataset in batches."""
batches = await plan_batches(data)
results = []
for i, batch in enumerate(batches, start=1):
results.append(await process(batch))
await ctx.report_progress(
progress=i,
total=len(batches),
message=f"Processed batch {i} of {len(batches)}",
)
return json.dumps(results)
Two details worth keeping:
ctxis not part of your tool's schema. The SDK recognises theContextannotation and supplies it, so the model still sees a one-argument tool. You get the session without paying for it in the interface the model reads.- Progress is advisory. Nothing waits on it and nothing retries it, so a host is free to ignore it entirely. Report progress to keep a human informed; never let correctness depend on a notification arriving.
Confirmable Actions
For dangerous operations, require confirmation:
@mcp.tool()
async def delete_all_files(directory: str, confirm: bool) -> str:
"""Delete every file in a directory. DESTRUCTIVE — cannot be undone.
Set confirm=true only after the user has explicitly agreed to this exact directory.
"""
if not confirm:
return "Action not confirmed. Ask the user to confirm the directory, then retry with confirm=true."
count = await delete_files(directory)
return f"Deleted {count} files from {directory}"
Note where the instruction lives: in the docstring, because that is what the model reads
before deciding. A confirm flag the model can set on its own is a speed bump, not a
safeguard — the real protection is the host asking a human, which is what tool annotations
and the host's own approval flow are for. Treat this pattern as a way to make the
destructive step legible, not as authorisation.
Tool Dependencies
Build tools that depend on other tools:
class ToolRegistry:
def __init__(self):
self.tools = {}
def register(self, name, handler):
self.tools[name] = handler
async def call(self, name, arguments):
return await self.tools[name](arguments)
registry = ToolRegistry()
async def get_user_handler(args):
return await db.get_user(args["id"])
async def get_user_orders_handler(args):
# Depends on get_user
user = await registry.call("get_user", {"id": args["user_id"]})
return await db.get_orders(user["id"])
registry.register("get_user", get_user_handler)
registry.register("get_user_orders", get_user_orders_handler)
Next, we'll explore advanced resource patterns. :::
Sign in to rate