Progress
A tool that takes thirty seconds and says nothing for thirty seconds looks broken.
Progress notifications fix that. The tool reports how far along it is; the client decides what to draw with it: a bar, a spinner, a log line.
Report it from the tool
Take a Context parameter and call report_progress:
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("Bookshop")
@mcp.tool()
async def import_catalog(urls: list[str], ctx: Context) -> str:
"""Import book records from a list of catalog URLs."""
for done, url in enumerate(urls, start=1):
await ctx.report_progress(done, total=len(urls), message=f"Imported {url}")
return f"Imported {len(urls)} records."
Three arguments, and you decide what they mean:
progress: how far you are. The spec requires it to increase with every report; never repeat a value or go backwards.total: how much there is in total, if you know. Optional.message: one human-readable line about this step. Optional.
ctx is injected because of its type hint and the model never sees it: import_catalog's input schema has a single property, urls. The Context chapter is all about that object; progress is one of the things it gives you.
Listen for it from the client
The client opts in per call, by passing progress_callback= to call_tool:
import anyio
from mcp import Client
from server import mcp
async def show(progress: float, total: float | None, message: str | None) -> None:
print(f"{message} ({progress}/{total})")
async def main() -> None:
async with Client(mcp) as client:
result = await client.call_tool(
"import_catalog",
{"urls": ["https://example.com/a.json", "https://example.com/b.json"]},
progress_callback=show,
)
print(result.structured_content)
anyio.run(main)
The callback is an async function taking exactly what the server reported: progress, total, message.
Info
Client(mcp) connects straight to the server object, in memory, the same client the Testing
chapter is built on. progress_callback is the same parameter whatever transport the Client
uses; the timing you are about to see is the in-memory connection's. It runs your callback
inline, so every report lands before call_tool returns. Over a real transport the
notifications race the result, and a slow callback can still be running after call_tool has
returned.
Try it
Put client.py next to server.py and run it:
python client.py
Imported https://example.com/a.json (1/2)
Imported https://example.com/b.json (2/2)
{'result': 'Imported 2 records.'}
Every await ctx.report_progress(...) on the server became one call to show on the client, in order, and both lines printed before call_tool returned. Progress is not bundled into the result; it streams while the tool is still working.
Warning
progress_callback belongs to the call, not the Client. There is no constructor argument
for it, because different calls want different callbacks: one drives a download bar, the next
one a log line.
Check
Now delete progress_callback=show and run it again:
{'result': 'Imported 2 records.'}
No error, no warning, same result. report_progress is a no-op when the caller didn't ask
for progress, so you report unconditionally and never have to wonder whether anyone is
listening.
When you don't know the total
total is for when you know the denominator. Often you don't: you're draining a feed, walking a cursor, downloading something with no length header.
Leave it out:
from collections.abc import AsyncIterator
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("Bookshop")
async def fetch_records(feed_url: str) -> AsyncIterator[str]:
for title in ("Dune", "Neuromancer", "Hyperion"):
yield f"{feed_url}#{title}"
@mcp.tool()
async def import_feed(feed_url: str, ctx: Context) -> str:
"""Import every record a catalog feed yields."""
imported = 0
async for record in fetch_records(feed_url):
imported += 1
await ctx.report_progress(imported, message=f"Imported {record}")
return f"Imported {imported} records."
The callback receives total=None. A client can still show activity ("3 imported so far...") but it can't show a percentage. Don't invent a total to get a prettier bar.
Tip
progress doesn't have to count anything in particular. Bytes, rows, pages: pick the unit the
user would recognise, and only promise a total you can keep.
Recap
await ctx.report_progress(progress, total=None, message=None)from any tool that takes aContext.- The client passes
progress_callback=tocall_tool: per call, never on theClient. - The callback is
async (progress, total, message) -> Noneand fires while the tool is still running. - No callback on the call means
report_progressdoes nothing. Report unconditionally. - Omit
totalwhen you don't know it; the callback getsNone.
Progress is what a running tool shows the user. The lines it logs for you, the person operating the server, are a different channel: Logging is next.