Guide · MCP Protocol
MCP server progress notifications
A tool that takes thirty seconds returns nothing to the user for thirty seconds. No spinner, no count, no indication of whether it is working or stuck. MCP's progress notification mechanism fixes this: a long-running tool handler can send incremental notifications/progress messages to the client while it executes, letting the host application show a progress bar or status line without waiting for the final result. The non-obvious parts are checking whether the client actually requested progress (not all do), choosing a notification granularity that is informative without flooding the connection, handling the error path cleanly, and configuring your uptime monitor so that intentionally slow tools do not trigger false-positive downtime alerts.
TL;DR
Check params._meta?.progressToken at the start of any long-running tool handler. If present, send notifications/progress messages via server.notification() during execution with { progressToken, progress, total }. If absent, execute silently — do not send progress notifications to clients that did not ask for them. Rate-limit notifications to roughly one every 500 ms for tight loops; send a final notification on both success and error paths so the progress indicator resolves cleanly. Configure your uptime probe timeout to match the tool's expected worst-case duration to avoid false-positive downtime alerts.
Why progress notifications exist
Standard MCP tool calls are synchronous from the client's perspective: the client sends tools/call and blocks waiting for the response. For a tool that returns in under a second, this is fine. For a tool that takes 10–120 seconds — a bulk data export, an LLM-assisted analysis, a migration dry-run — the client has no signal distinguishing "running normally" from "hung."
Progress notifications solve this with a side channel. The tool handler sends unsolicited notifications/progress messages to the client while the main tools/call response is pending. The host application can display these as a progress bar, a percentage, a step counter, or a status line — without modifying the tool's return value.
The mechanism is entirely opt-in by the client. A client that wants progress updates includes a _meta.progressToken field in the tools/call request. Clients that do not include it receive no notifications. Server-side, progress notifications are only sent when a token is present — making the feature backward-compatible with any client.
Reading the progressToken
The MCP SDK passes the full request parameters to your tool handler, including _meta. The progressToken is either a string or a number — both are valid, and you must echo it back verbatim in every notification you send.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const server = new McpServer({ name: 'export-server', version: '1.0.0' });
server.tool(
'export_records',
{
table: z.string(),
limit: z.number().int().positive().max(100000).default(10000),
},
async (args, extra) => {
const progressToken = extra.meta?.progressToken;
// Helper — sends only if client requested progress
const sendProgress = async (progress: number, total: number, message?: string) => {
if (progressToken === undefined) return;
await server.notification({
method: 'notifications/progress',
params: { progressToken, progress, total, message },
});
};
await sendProgress(0, args.limit, 'Starting export…');
const rows = await db.query(`SELECT * FROM ${args.table} LIMIT $1`, [args.limit]);
let processed = 0;
const output: string[] = [];
let lastNotifyAt = 0;
for (const row of rows) {
output.push(JSON.stringify(row));
processed++;
const now = Date.now();
if (now - lastNotifyAt >= 500) { // rate limit: one notification per 500 ms
await sendProgress(processed, rows.length, `Exported ${processed} of ${rows.length} rows`);
lastNotifyAt = now;
}
}
await sendProgress(rows.length, rows.length, 'Export complete');
return {
content: [{ type: 'text', text: output.join('\n') }],
};
}
);
Two things to note: extra.meta?.progressToken accesses the field (not params._meta directly — the SDK normalises it into extra.meta); and the rate limit guard now - lastNotifyAt >= 500 prevents flooding the SSE connection when iterating thousands of rows.
Progress notification payload
The notifications/progress method takes a params object with these fields:
| Field | Type | Required | Notes |
|---|---|---|---|
progressToken | string | number | Yes | Echo verbatim from the tool call request |
progress | number | Yes | Current value — e.g. rows processed, bytes written, steps completed |
total | number | No | Total value when known — enables percentage calculation; omit if total is unknown until completion |
message | string | No | Human-readable status line — hosts may display this alongside the progress bar |
When the total is not known up front — for example, when paginating an API with no page count in the first response — omit total and use progress as an indeterminate counter (pages fetched, items seen). Clients that render progress bars will show a spinner instead of a percentage, which is accurate.
// Known total
await server.notification({
method: 'notifications/progress',
params: { progressToken, progress: 42, total: 100, message: '42% complete' },
});
// Unknown total
await server.notification({
method: 'notifications/progress',
params: { progressToken, progress: 3, message: 'Fetched page 3…' },
});
Rate limiting and notification granularity
Sending one notification per loop iteration is almost always wrong. A loop that processes 50,000 rows at 10,000 rows/second would send 50,000 SSE frames in 5 seconds, saturating the connection and making the client UI thrash. The right approach is time-gated: check the wall clock and send at most one notification per interval.
| Tool type | Recommended interval | Rationale |
|---|---|---|
| Tight loop (thousands of iterations) | 500 ms | Smooth updates without flooding |
| Staged pipeline (5–20 discrete steps) | One per step | Steps are natural progress points |
| External API calls (N sequential requests) | One per request | Each request already takes hundreds of ms |
| Long single operation (>30 s, no sub-steps) | Every 5 s | Heartbeat to show it is alive |
For staged pipelines, send a notification at the start of each stage rather than at the end. This way the host shows "Running database query…" while the query is in flight, rather than showing it after the query completes (when the UI update is no longer useful).
Error paths and final notifications
Always send a final progress notification before returning an error result. If you do not, the host application may leave its progress indicator in a half-finished state when the tool returns isError: true. A final notification with progress === total (or a dedicated error message) signals to the host that the operation has ended.
server.tool('run_migration', { ... }, async (args, extra) => {
const progressToken = extra.meta?.progressToken;
const notify = async (p: number, t: number, msg: string) => {
if (progressToken !== undefined) {
await server.notification({
method: 'notifications/progress',
params: { progressToken, progress: p, total: t, message: msg },
});
}
};
const steps = ['schema check', 'backup', 'apply migrations', 'verify', 'cleanup'];
await notify(0, steps.length, 'Starting migration dry-run…');
for (let i = 0; i < steps.length; i++) {
await notify(i, steps.length, `Step ${i + 1}/${steps.length}: ${steps[i]}`);
try {
await runStep(steps[i], args);
} catch (err) {
// Send a terminal notification so the host resolves its progress bar
await notify(steps.length, steps.length, `Failed at step: ${steps[i]}`);
return {
content: [{ type: 'text', text: `Migration failed at "${steps[i]}": ${err.message}` }],
isError: true,
};
}
}
await notify(steps.length, steps.length, 'Migration complete');
return { content: [{ type: 'text', text: 'Migration applied successfully.' }] };
});
Infrastructure requirements for notifications
Progress notifications are sent over the SSE connection that the MCP client maintains for server-to-client messages. Several reverse-proxy configurations interfere with SSE unless you explicitly disable response buffering:
| Reverse proxy | Required setting |
|---|---|
| Caddy | flush_interval -1 in the reverse_proxy block |
| nginx | proxy_buffering off on the /mcp and /sse location blocks |
| AWS ALB | No change needed — ALB does not buffer SSE |
| Cloudflare (free/pro) | 100-second max; send SSE keep-alive comment every 90 s for long tools |
| Kubernetes nginx Ingress | nginx.ingress.kubernetes.io/proxy-buffering: "off" annotation |
If progress notifications are not appearing in the client even though your tool is sending them, proxy buffering is almost always the cause. Confirm by running npx @modelcontextprotocol/inspector against your server directly (bypassing the proxy) and checking whether the Protocol Log panel shows notifications/progress events. If they appear in Inspector but not in Claude Desktop or Cursor, the proxy is buffering them.
Monitoring long-running tools
Progress notifications create a monitoring challenge: your uptime probe sends tools/call and then waits for a response. If the tool legitimately takes 60 seconds, a 10-second probe timeout will fire a false-positive alert every time the probe runs.
The correct approach has two parts. First, configure your probe to bypass the long-running code path. If the tool accepts arguments that determine whether it runs a fast smoke-test path or the full operation, use arguments in the probe that trigger the fast path. Second, configure your monitoring provider's per-probe timeout to match the tool's worst-case duration with a buffer.
For AliveMCP, set a custom timeout per monitor. The default is 30 seconds; for tools that legitimately take 90 seconds, configure a 120-second probe timeout. AliveMCP measures the time from the first byte of the tools/call request to the last byte of the response, which covers the full duration of any in-flight progress notification stream.
A second concern is stuck tools — tools that send a few progress notifications and then stop, neither returning a result nor sending more notifications. These are indistinguishable from healthy-but-slow tools from the client's perspective. The best mitigation is an application-level watchdog: a Promise.race between the tool's main logic and a timeout that returns isError: true after the maximum acceptable duration, with a terminal progress notification.
const MAX_DURATION_MS = 90_000;
server.tool('long_analysis', { ... }, async (args, extra) => {
const progressToken = extra.meta?.progressToken;
const result = await Promise.race([
runAnalysis(args, progressToken),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Analysis timed out after 90 seconds')), MAX_DURATION_MS)
),
]).catch(async (err) => {
if (progressToken !== undefined) {
await server.notification({
method: 'notifications/progress',
params: { progressToken, progress: 0, total: 1, message: err.message },
});
}
return { content: [{ type: 'text', text: err.message }], isError: true };
});
return result;
});
Frequently asked questions
What happens if I send progress notifications to a client that did not include a progressToken?
The notifications will be sent over the SSE connection but the client has no matching progressToken to correlate them with, so they are typically silently ignored. However, there is no protocol-level harm — the client is not required to error on unexpected notifications. That said, sending notifications when none were requested is wasteful, adds unnecessary SSE traffic, and may confuse client-side progress tracking. Always check for progressToken before sending any notification.
Can I send progress notifications from a stdio transport server?
Yes. Progress notifications are part of the MCP JSON-RPC protocol, which runs identically over both SSE/HTTP and stdio transports. Over stdio, notifications are written to stdout as JSON-RPC notification objects interleaved with responses. The same check-for-progressToken pattern applies. The infrastructure buffering concern (proxy flush_interval) does not apply to stdio servers — stdout is unbuffered by default in Node.js for terminal output.
How do I test that my progress notifications are being sent correctly?
Use npx @modelcontextprotocol/inspector and connect it to your server. Run the tool via the Inspector's tool call interface. The Protocol Log panel shows every JSON-RPC message in real time, including notifications/progress events. Verify the progressToken value matches the one in the tools/call request, that progress values are monotonically increasing (or at least non-decreasing), and that a final notification with progress === total appears before the tool result. For automated testing, use InMemoryTransport.createLinkedPair() and assert on notifications received via the client's notification handler.
Does sending progress notifications affect the tool's return value?
No. Progress notifications are side-channel messages; the tool's return value is unaffected. The tools/call response contains only content and optionally isError, exactly as it would without progress. The client receives the final return value as the resolution of the callTool() promise — notifications arrive during the wait and are delivered to a separate notification handler. You do not include progress information in the return value; it arrives via the notification stream.
My long-running tool is triggering AliveMCP false-positive alerts. What should I configure?
Configure a per-monitor timeout in AliveMCP that exceeds your tool's worst-case duration. If the tool takes up to 90 seconds, set the probe timeout to 120 seconds. Additionally, if your tool accepts arguments that select a fast verification path, configure AliveMCP's probe to use those arguments — this keeps probe execution fast (under 5 seconds) without losing the ability to monitor the tool's endpoint reachability. See MCP server uptime monitoring for probe configuration patterns.
Further reading
- MCP server cancellation — handling client-initiated tool cancellation
- MCP server session lifecycle — connection management and reconnection
- MCP server streaming — SSE transport and long-response patterns
- MCP server message queue — async job patterns for tasks that take minutes
- MCP server tool design — naming, input schemas, and output formats
- AliveMCP — continuous protocol monitoring for MCP servers