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:

FieldTypeRequiredNotes
progressTokenstring | numberYesEcho verbatim from the tool call request
progressnumberYesCurrent value — e.g. rows processed, bytes written, steps completed
totalnumberNoTotal value when known — enables percentage calculation; omit if total is unknown until completion
messagestringNoHuman-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 typeRecommended intervalRationale
Tight loop (thousands of iterations)500 msSmooth updates without flooding
Staged pipeline (5–20 discrete steps)One per stepSteps are natural progress points
External API calls (N sequential requests)One per requestEach request already takes hundreds of ms
Long single operation (>30 s, no sub-steps)Every 5 sHeartbeat 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 proxyRequired setting
Caddyflush_interval -1 in the reverse_proxy block
nginxproxy_buffering off on the /mcp and /sse location blocks
AWS ALBNo 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 Ingressnginx.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

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free