Home Blog Contact
Home/Blog/How to Stream Progress From a Long-Running MC…
How toLLM EngineeringMCPMCP progressPython SDK

How to Stream Progress From a Long-Running MCP Tool

8 min readBy Miloš Mitrović

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 Context argument in your tool and call await ctx.report_progress(progress, total, message) inside the work loop.
  • Progress only flows when the client puts a progressToken in the request _meta. Without a token, report_progress is a no-op and nothing appears.
  • The progress value MUST increase on every notification, even when the total is unknown.
  • total and message are optional; a client that receives both may render progress/total as 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 mcp package. pip install mcp now pulls the v2 line, which exposes MCPServer; the v1.x line uses FastMCP with the same Context API.
  • 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?

  1. Install the SDK and pin the major version so a future release does not surprise you.

    pip install "mcp>=2,<3"
  2. Add a Context parameter 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, then FastMCP(name=...). The tool body is identical. This is the pattern used in the SDK's own tool_progress.py example.

  3. Understand the three arguments. report_progress(progress, total=None, message=None) maps straight onto the wire format: progress is how far along you are, total is the optional ceiling, and message is human-readable text. Reporting 0.6 against a total of 1.0 is the same idea as 60 against 100.

  4. Keep the values increasing. The MCP progress specification requires progress to rise on every notification, even when total is unknown. Send a growing counter, never a value that stalls or drops, or a strict client may discard the update.

  5. Request progress from the client. In the Python client you pass a progress_callback to call_tool. The SDK generates the token, injects it into the request _meta, and invokes your callback for each notifications/progress message.

    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, and message in that order. If you are building the client from scratch, the Python SDK repository ships the matching ClientSession.

  6. 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 the progress_callback argument on call_tool. Without it the SDK sends no token and report_progress silently does nothing. Test in the Inspector to rule out the server.
  • Updates stop halfway or get dropped. Your progress value 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 await on report_progress cannot 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/cancelled flow.
  • 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_callback plugs in.

Sources

M
Miloš Mitrović
Email Marketing for Ecommerce

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.