Progress Notifications in MCP
MCP progress notifications solve a problem every builder of Model Context Protocol servers eventually hits: a tool call that takes ten, thirty, or ninety seconds and gives the client nothing to show while it waits. A file indexer walking a large repo, a web scraper crawling a hundred pages, a batch embedding job, a video transcode; all of these are legitimate MCP tool calls, and all of them look identical to a hung connection if the server stays quiet until the final response. MCP progress notifications fix this by letting the server push incremental updates over the same connection, tied to the original request through a progress token, while the tool is still running.
This article walks through the mechanics of the progress token, how to emit progress notifications from a server written in Python or TypeScript, how a client is supposed to receive and render them, and where people usually get the implementation wrong.
What MCP Progress Notifications Actually Are
MCP progress notifications are a JSON-RPC notification (no reply expected, no id field) that a server sends to a client while a request is still in flight. The notification method is notifications/progress, and its payload carries three fields: a progressToken that ties the notification back to the original request, a progress number, and two optional fields, total and message.
A minimal progress notification looks like this on the wire:
{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"progressToken": "abc-123",
"progress": 40,
"total": 100,
"message": "Indexed 40 of 100 files"
}
}Notice what is missing: there is no result field and no id. This is not a response to the tool call, it is a side-channel event sent on the same transport while the tool call's original request is still open. The actual tool result (the final CallToolResult) still arrives later as a normal JSON-RPC response.
This is the same pattern used by build systems and CI pipelines that stream log lines before returning an exit code, applied to the request/response shape of JSON-RPC. The client does not poll for status; the server pushes it.
Why Progress Notifications Matter for Long-Running Tools
Without progress notifications, an MCP client (Claude Desktop, Claude Code, a custom agent host) has exactly two states to show the user for a tool call: "running" and "done." For a tool that finishes in under a second that is fine. For a tool that scans a directory tree, calls an external API in a loop, or runs a multi-step pipeline, that binary state creates two real problems.
First, the user cannot tell if the tool is making progress or stuck. A thirty-second wait with no feedback is indistinguishable from a deadlocked connection, and users abandon or retry requests that are actually working fine.
Second, there is no way to communicate partial results or intermediate context. A scraper that has already found 30 relevant pages out of an expected 50 can say so. A migration script that just finished step 3 of 7 can name the step. Progress notifications turn a black box into a narrated process, which is exactly what makes agentic workflows feel trustworthy instead of opaque.
There is a secondary benefit for debugging: when you are developing an MCP server, progress messages become a lightweight instrumentation channel you get for free, visible in any MCP-compliant client or in a raw JSON-RPC log, without needing a separate logging pipeline.
The progressToken Parameter
Progress notifications only fire for requests that opt in. The client decides whether it wants progress updates by attaching a progressToken to the _meta field of the original request's params. If the client does not send a token, the server must not send progress notifications for that call; there is nothing to correlate them to.
A tool call request that opts into progress tracking looks like this:
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "index_repository",
"arguments": { "path": "/workspace/my-repo" },
"_meta": {
"progressToken": "index-repo-7"
}
}
}The token can be a string or a number. Servers should treat it as an opaque identifier: generate nothing from it, do not assume it maps to the JSON-RPC id, just echo it back on every progress notification associated with that call. Once the server sends the final result for request 7, it should stop sending progress notifications tied to index-repo-7. A server that keeps sending progress after the result has already gone out is violating the request lifecycle and will confuse clients that discard the token as soon as the call resolves.
Sending Progress Notifications from a Server
The general shape, independent of language, is:
- The tool handler receives the request and reads the
progressTokenout of_meta, if present. - As the handler does real work, it periodically sends
notifications/progressmessages carrying that token. - When the handler finishes, it returns the normal tool result. No progress notification is required at that point; the result itself signals completion.
If the client did not send a progressToken, the handler should simply skip step 2 and run normally. Both SDKs make this the default behavior so you rarely branch on it manually.
Building a Progress-Aware MCP Tool in Python
The Python MCP SDK exposes a Context object to tool functions when you declare it as a parameter. That context has a report_progress method that handles the token lookup and notification framing for you.
from mcp.server.fastmcp import FastMCP, Context
import asyncio
mcp = FastMCP("repo-indexer")
@mcp.tool()
async def index_repository(path: str, ctx: Context) -> str:
"""Walk a repository and report progress as files are indexed."""
files = list_files(path)
total = len(files)
for i, file_path in enumerate(files, start=1):
index_file(file_path)
await ctx.report_progress(
progress=i,
total=total,
message=f"Indexed {i} of {total} files"
)
return f"Indexed {total} files under {path}"
def list_files(path: str) -> list[str]:
import os
out = []
for root, _, names in os.walk(path):
for name in names:
out.append(os.path.join(root, name))
return out
def index_file(file_path: str) -> None:
# placeholder for real indexing work
passIf the incoming request had no progressToken, ctx.report_progress becomes a no-op internally; you do not need to guard the call with an if check. This is the main reason to prefer the Context-based API over hand-rolling JSON-RPC notifications yourself: the SDK already handles "was progress requested" for you.
For CPU-bound work where you cannot easily interleave await points, throttle the notification rate instead of firing one per unit of work. Sending a notification per file across ten thousand files will flood the transport and can make the client slower, not more responsive:
@mcp.tool()
async def index_repository(path: str, ctx: Context) -> str:
files = list_files(path)
total = len(files)
report_every = max(1, total // 100) # cap at roughly 100 updates
for i, file_path in enumerate(files, start=1):
index_file(file_path)
if i % report_every == 0 or i == total:
await ctx.report_progress(progress=i, total=total)
return f"Indexed {total} files under {path}"Building a Progress-Aware MCP Tool in TypeScript
The TypeScript MCP SDK passes an extra argument to tool handlers registered through server.tool(...) or server.registerTool(...). That argument carries a sendNotification function and the original request's _meta, from which you pull the token.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "repo-indexer",
version: "1.0.0",
});
server.registerTool(
"index_repository",
{
title: "Index Repository",
description: "Walk a repository and report progress as files are indexed.",
inputSchema: { path: z.string() },
},
async ({ path }, extra) => {
const files = await listFiles(path);
const total = files.length;
const progressToken = extra._meta?.progressToken;
for (let i = 0; i < total; i++) {
await indexFile(files[i]);
if (progressToken !== undefined) {
await extra.sendNotification({
method: "notifications/progress",
params: {
progressToken,
progress: i + 1,
total,
message: `Indexed ${i + 1} of ${total} files`,
},
});
}
}
return {
content: [{ type: "text", text: `Indexed ${total} files under ${path}` }],
};
}
);
async function listFiles(path: string): Promise<string[]> {
// real implementation would walk the filesystem
return [];
}
async function indexFile(filePath: string): Promise<void> {
// placeholder for real indexing work
}
const transport = new StdioServerTransport();
await server.connect(transport);The explicit progressToken !== undefined check matters more here than in the Python SDK, because sendNotification in the TypeScript SDK does not silently no-op for you the way report_progress does; calling it with an undefined token produces a malformed notification. Always guard the call.
Same throttling advice applies: batch by percentage or by a fixed interval rather than firing on every loop iteration when the loop count can be large.
Receiving Progress Notifications on the Client
On the client side, you register a notification handler when you issue the request, and you attach a token when you build the request. Using the TypeScript client SDK:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "node",
args: ["repo-indexer-server.js"],
});
const client = new Client({ name: "indexer-client", version: "1.0.0" });
await client.connect(transport);
const result = await client.callTool(
{
name: "index_repository",
arguments: { path: "/workspace/my-repo" },
},
undefined,
{
onprogress: (progress) => {
console.log(`${progress.progress}/${progress.total ?? "?"} ${progress.message ?? ""}`);
},
}
);
console.log(result.content);The client SDK generates the progressToken for you and wires up the correlation automatically when you pass an onprogress callback; you do not need to invent a token yourself in most cases. If you are hand-rolling a client against the raw JSON-RPC transport instead of using an SDK, you own that correlation step manually: generate a unique token per request, attach it under _meta.progressToken, and keep a lookup table from token to whatever UI element (progress bar, log pane, spinner label) should update when a matching notification arrives.
One detail that trips people up: progress notifications for a given token can arrive in any order relative to other traffic on the connection, and a well-behaved client should not assume monotonically increasing progress values are guaranteed by the spec, only that they are the convention every real server follows. Defensive clients clamp displayed progress rather than trusting it blindly.
Progress Notifications vs Logging Notifications
MCP also defines notifications/message for structured log output (debug, info, warning, error levels), and it is easy to reach for that instead of progress notifications because both are "server tells client something mid-call." They are not interchangeable.
Logging notifications are unstructured, level-tagged text meant for observability: think of them as what you would otherwise print to stderr. They have no numeric progress semantics and are not tied to a specific in-flight request via a token; a client can subscribe to a minimum log level globally and get messages from any tool call.
Progress notifications are structured, numeric, and scoped to exactly one request via progressToken. Use progress notifications when you want a client to render a progress bar, percentage, or step counter for a specific call. Use logging notifications when you want to surface diagnostic detail that is not really "how far along are we" but "here is something worth knowing." A server indexing a repository might send progress notifications for the file count and a logging notification if it hits a permission error on a specific file it has to skip.
Common Pitfalls
Sending progress with no token. If the request did not include _meta.progressToken, do not send notifications/progress for it. There is nothing on the client side to correlate the notification to, and some clients will log this as a protocol error.
Sending progress after the result. Once you return the final CallToolResult, the request lifecycle for that call is over. A progress notification that arrives after the result is undefined behavior in most client implementations; some will drop it silently, others may throw. Emit your last progress update, then return.
Progress values that do not move. If total is unknown ahead of time (say, a crawl with an unbounded frontier), omit total rather than guessing a number and never updating it. A client rendering a percentage against a total that never gets hit looks broken even though the server is working correctly. Report progress alone as a monotonically increasing counter when there is no natural denominator.
Flooding the transport. Especially over stdio transports, sending a notification per unit of work on a tight loop (thousands of files, embedding batches, etc.) adds real overhead and can make the whole connection sluggish. Throttle to a sensible cadence, either a fixed number of updates total or a minimum time interval between sends.
Blocking work with no interleave points. In Python, if your loop body is synchronous CPU-bound work with no await, the event loop cannot flush the notification until the next await point. Either break long synchronous stretches with await asyncio.sleep(0) at your throttled checkpoints, or move the work to a thread pool executor and report progress from the coordinating coroutine.
Assuming every client renders progress. Not every MCP host UI surfaces progress notifications visually. Some log them to a debug console, some ignore them outright if the client library was configured without an onprogress handler. Progress notifications are a nice-to-have channel, not a substitute for a tool eventually returning a correct result; never make the final CallToolResult depend on progress notifications having been received.
Testing Progress Notifications
The fastest way to verify progress wiring is to run your server through the MCP Inspector, which shows raw JSON-RPC traffic including notifications/progress frames as they stream in, separate from the final tool result. Point it at your server over stdio and call the tool manually; you should see a sequence of progress frames followed by exactly one result frame, and the progressToken on every progress frame should match what you sent in the request's _meta.
If you are testing programmatically, write a small script that opens a client connection, issues the call with a known token, collects every notifications/progress payload into a list via the onprogress callback (or a raw notification handler if you are not using an SDK), and assert on that list after the call resolves: the token matches on every entry, progress is non-decreasing if you claimed monotonic behavior, and the last progress value equals total when total was known ahead of time. That single assertion set catches the two most common bugs: mismatched tokens and off-by-one final progress values that never quite reach 100 percent.
FAQ
Does every MCP tool need to support progress notifications? No. Progress notifications are an optional protocol feature meant for calls that take long enough to benefit from incremental feedback. A tool that returns in well under a second gains nothing from adding progress reporting and the extra notifications just add noise.
What happens if the client does not send a progressToken? The server should not send any notifications/progress messages for that call. Both the Python and TypeScript SDK helpers already skip emission when no token is present, so a correctly written tool works identically whether or not the client opted in.
Can progress notifications include arbitrary data, not just a number? The message field accepts a free-text string, which is the sanctioned place to put human-readable status like "Indexed 40 of 100 files" or "Waiting on rate limit." The protocol does not define an arbitrary structured-data field on progress notifications; if you need to pass structured intermediate results, that is a separate design problem better solved with logging notifications or by returning intermediate content through a different mechanism.
Is the progress value required to be a percentage? No. progress is just a number that increases as work proceeds; pairing it with total lets a client compute a percentage, but you can also send progress alone as a raw counter (bytes processed, rows written) when there is no meaningful denominator.
Do progress notifications work over both stdio and HTTP transports? Yes. Progress notifications are part of the base JSON-RPC notification layer, so they work over any transport MCP supports, including stdio and streamable HTTP. The framing is identical; only the underlying delivery mechanism differs.
Can a single tool call send progress notifications with different tokens? No, a single tool call should only ever use the one progressToken supplied in that call's _meta. If your tool internally fans out to multiple sub-tasks, aggregate their progress into one running total under the single token rather than sending notifications with different tokens on the same connection.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.