Skip to content

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:

server.py
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:

client.py
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:

server.py
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 a Context.
  • The client passes progress_callback= to call_tool: per call, never on the Client.
  • The callback is async (progress, total, message) -> None and fires while the tool is still running.
  • No callback on the call means report_progress does nothing. Report unconditionally.
  • Omit total when you don't know it; the callback gets None.

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.