A tool that runs for thirty seconds looks broken if the user is left staring at a spinner with no signal. MCP solves this with progress notifications: the client tags your request with a token, and your tool streams status messages back while it works. In the Python SDK the whole mechanism is one call, await ctx.report_progress(), made inside the tool.
Here is the short version, then the exact code, the client side, and the errors that trip people up.
Key Takeaways
- Short answer: accept a
Contextargument in your tool and callawait ctx.report_progress(progress, total, message)inside the work loop. - Progress only flows when the client puts a
progressTokenin the request_meta. Without a token,report_progressis a no-op and nothing appears. - The
progressvalue MUST increase on every notification, even when the total is unknown. totalandmessageare optional; a client that receives both may renderprogress/totalas a percentage.- Over streamable HTTP the updates need a streaming response. A single buffered JSON response swallows the interim notifications.
- Rate-limit your updates. The spec tells both sides to guard against flooding.
What You Need
- Python 3.10 or newer and the official
mcppackage.pip install mcpnow pulls the v2 line, which exposesMCPServer; the v1.x line usesFastMCPwith the sameContextAPI. - A tool that does real work in a loop (file processing, a batch job, a multi-step API crawl). Progress on a one-shot call has nothing to report.
- A way to view notifications. The MCP Inspector or any host that surfaces progress will do.
How Do You Stream Progress From an MCP Tool?
Install the SDK and pin the major version so a future release does not surprise you.
pip install "mcp>=2,<3"Add a
Contextparameter to your tool. The SDK injects it by type annotation, so it never shows up in the tool's input schema.from mcp.server.mcpserver import Context, MCPServer mcp = MCPServer(name="Progress Example") @mcp.tool() async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: """Execute a task with progress updates.""" for i in range(steps): progress = (i + 1) / steps await ctx.report_progress( progress=progress, total=1.0, message=f"Step {i + 1}/{steps}", ) return f"Task '{task_name}' completed"On the v1.x line the only change is the import:
from mcp.server.fastmcp import Context, FastMCP, thenFastMCP(name=...). The tool body is identical. This is the pattern used in the SDK's own tool_progress.py example.Understand the three arguments.
report_progress(progress, total=None, message=None)maps straight onto the wire format:progressis how far along you are,totalis the optional ceiling, andmessageis human-readable text. Reporting0.6against atotalof1.0is the same idea as60against100.Keep the values increasing. The MCP progress specification requires
progressto rise on every notification, even whentotalis unknown. Send a growing counter, never a value that stalls or drops, or a strict client may discard the update.Request progress from the client. In the Python client you pass a
progress_callbacktocall_tool. The SDK generates the token, injects it into the request_meta, and invokes your callback for eachnotifications/progressmessage.async def on_progress(progress: float, total: float | None, message: str | None) -> None: pct = f"{progress / total:.0%}" if total else f"{progress}" print(f"[{pct}] {message or ''}") result = await session.call_tool( "long_running_task", {"task_name": "reindex", "steps": 10}, progress_callback=on_progress, )The callback signature is fixed: it receives
progress,total, andmessagein that order. If you are building the client from scratch, the Python SDK repository ships the matchingClientSession.Watch it run. Point the MCP Inspector at your server, call the tool, and confirm the progress notifications arrive in order before the final result. If they show up in the Inspector, the server side is correct and any missing progress in a host is a client-side gap.
How Does the Notification Look on the Wire?
The high-level SDK hides the JSON, but knowing the shape helps when you debug. The client attaches a token to the request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "_meta": { "progressToken": "abc123" } }
}
Each ctx.report_progress call then emits one notification that echoes the same token:
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "abc123",
"progress": 50,
"total": 100,
"message": "Reticulating splines..."
}
}
Two rules from the spec matter here. The token MUST be unique across all active requests, and notifications MUST stop once the operation completes. The SDK enforces both for you, but a hand-rolled client or server has to honor them.
How Do You Pace Updates Without Flooding the Client?
A tight loop that fires a notification per iteration can drown the client in messages. The spec asks both sides to rate-limit, so throttle on the server: send an update only when the percentage crosses a whole point, or on a short time interval, rather than on every unit of work.
For a job with 50,000 records, report every 500 records or once a second, not once per record. The user cannot read faster than that, and the transport does not need to.
Troubleshooting the Common Failures
- No progress shows up at all. The client did not send a
progressToken. In Python that means you forgot theprogress_callbackargument oncall_tool. Without it the SDK sends no token andreport_progresssilently does nothing. Test in the Inspector to rule out the server. - Updates stop halfway or get dropped. Your
progressvalue is not strictly increasing. A resumed loop or a reset counter can send a value at or below the previous one, and a strict client drops it. Track an absolute counter, not a per-batch one. - Notifications never arrive over HTTP even though stdio works. Streamable HTTP delivers interim messages on the streaming (SSE) leg of the response. If the transport returns one buffered JSON body instead, the progress notifications are lost, a known effect of the JSON-response mode discussed in this TypeScript SDK issue. Keep the streaming response enabled for tools that report progress.
- The tool blocks and flushes everything at the end. Synchronous CPU work never yields control, so the
awaitonreport_progresscannot flush until the loop ends. Make the tool genuinely async, or offload the heavy step to a thread or process pool and await it between updates.
What to Do Next
- Pair progress with cancellation so a user can abort a long job cleanly. The cancellation utility in the same spec section covers the
notifications/cancelledflow. - If you are still on stdio and want progress to reach a remote host, read how to serve MCP over streamable HTTP, the transport that carries these notifications to remote clients.
- Build the receiving end deliberately if you own both halves; the walkthrough on how to build an MCP client in Python shows where the
progress_callbackplugs in.