Skip to main content
All posts

Long-Running MCP App Tools: Progress, Tasks, Polling, and Cancellation (October 2026)

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkProgress NotificationsMCP Tasks
Long-running MCP App tools need a progress contract, a durable job model, and UI states for running, failed, cancelled, and complete work.

Long-running MCP App tools need a progress contract, a durable job model, and UI states for running, failed, cancelled, and complete work.

Builders hit a new problem as soon as an MCP App runs a report, imports records, syncs an account, starts a deployment, or generates a large artifact:

  • Should the MCP tool stay open until the work finishes?
  • Where do progress updates go?
  • Is an MCP Task different from a progress notification?
  • How does a ChatGPT App or Claude Connector show a job that is still running?
  • What happens when the user closes the iframe, stops the model, or clicks Cancel?

TL;DR: Treat long-running MCP App work as a job with a durable status model. Use progress notifications for an active request that supplied a progress token. For work that outlives the request, return a job ID or, when both sides support it, an MCP Task handle. Render status in the app resource, poll at a controlled rate, distinguish a cancellation request from a confirmed cancellation, and test every state locally.

The Decision Rule

Start with the shortest design that can tell the truth about the work.

Work shapeUseWhy
Finishes quickly and returns one resultNormal synchronous tool callThe host can wait and render the final result
Takes a while but the request stays openTool call plus progress notificationsThe server can report phases before returning
May outlive host, proxy, or platform timeoutsStart job plus status toolThe UI can keep checking a durable server record
Needs a standard durable handle across clientsMCP Tasks, when supportedThe task lifecycle becomes part of the protocol contract
Runs after the user leaves the iframeServer-side job, not a view toolThe work survives iframe teardown

Do not make the rendered resource own durable work. The iframe can be reloaded, hidden, moved between display modes, or torn down. Put long-running work on the MCP server or behind it, then let the resource watch and control that work.

What Progress Notifications Are For

MCP has a progress utility for active requests. The client includes a progress token in request metadata, and the server can send notifications/progress messages tied to that token. A notification contains a progress value, with an optional total and message. The progress value must increase on each notification, and the server must stop sending updates when the request finishes.

That makes sense for work that is still inside the request:

  • Indexing the first batch before returning search results.
  • Validating a large import file before returning an import preview.
  • Building a report while the host keeps the tool call open.
  • Running a test suite where the final result is still expected in the same request.

Progress notifications are not a database. If the request ends, the progress stream ends. If the client disconnects, the progress stream may disappear. If a host does not surface progress notifications in its UI, the user may still only see a loading state.

Use them as a live signal, not as your only state. The current Tasks extension has its own status path, so do not design a Task UI around notifications/progress arriving after the start response.

type ProgressUpdate = {
  progress: number;
  total?: number;
  message?: string;
};

async function generateReport(
  args: { accountId: string },
  sendProgress: (update: ProgressUpdate) => Promise<void>,
) {
  await sendProgress({ progress: 1, total: 4, message: 'Loading account data' });
  const account = await loadAccount(args.accountId);

  await sendProgress({ progress: 2, total: 4, message: 'Building charts' });
  const charts = await buildCharts(account);

  await sendProgress({ progress: 3, total: 4, message: 'Writing summary' });
  const summary = await writeSummary(account, charts);

  await sendProgress({ progress: 4, total: 4, message: 'Report ready' });

  return {
    content: [{ type: 'text' as const, text: 'Report ready.' }],
    structuredContent: { accountId: args.accountId, summary, chartCount: charts.length },
  };
}

The exact API for sending progress depends on the MCP SDK or framework you use. The contract to design around is stable: progress belongs to an active request and should be safe to lose.

Why Jobs Are Usually the Safer Shape

Many app workflows take longer than one request should.

A data sync might wait on rate limits. A report might run in a queue. A deployment might need several systems to agree. A file conversion might take minutes for a large document. Holding one tool call open through that whole process adds avoidable failure modes:

  • The host may cancel the request.
  • A CDN or platform may close the connection.
  • The user may navigate away.
  • The iframe may be torn down.
  • A worker may need to retry after the original request is gone.

The safer shape is start, status, action, result.

ToolVisibilityJob
start_reportmodel and appCreates the job, returns jobId, status, summary, and resource URI
get_report_statusappReturns the latest status, progress, phase, and short log tail
cancel_reportappRequests cancellation and returns the new status
retry_reportappStarts a retry for failed or cancelled jobs when allowed
get_report_resultapp or modelReturns the final artifact summary or signed download reference

The model-visible start_report tool starts the workflow and renders the resource. The app-only tools keep the UI current without adding noise to the model-facing tool list.

A Job Result Shape That Works

Keep the job contract boring. You want a shape that can render every state without guessing.

type JobStatus = 'queued' | 'running' | 'cancel_requested' | 'succeeded' | 'failed' | 'cancelled' | 'timed_out';

type ReportJobView = {
  jobId: string;
  status: JobStatus;
  percent: number | null;
  phase: string;
  summary: string;
  canCancel: boolean;
  canRetry: boolean;
  startedAt: string;
  updatedAt: string;
  result?: {
    title: string;
    artifactId: string;
  };
  error?: {
    code: string;
    message: string;
  };
};

Use structuredContent for the part the model may need:

return {
  content: [{ type: 'text' as const, text: `Started report job ${job.id}.` }],
  structuredContent: {
    jobId: job.id,
    status: job.status,
    percent: job.percent,
    phase: job.phase,
    summary: job.summary,
    canCancel: job.canCancel,
    canRetry: job.canRetry,
    startedAt: job.startedAt,
    updatedAt: job.updatedAt,
  } satisfies ReportJobView,
  _meta: {
    logTail: job.logTail.slice(-50),
    traceId: job.traceId,
  },
};

That split matters. The model may need the job status and final result summary. It usually does not need raw logs, internal queue names, trace IDs, temporary storage URLs, or worker payloads.

If your resource needs those details for display, put them in _meta when supported, or fetch them through an app-only tool after the resource loads.

Rendering the Job in the App

The resource should treat the initial tool result as a snapshot, then refresh status through the host bridge.

import { useEffect, useState } from 'react';
import { SafeArea, useCallServerTool, useTeardown, useToolData } from 'sunpeak';

type JobStatus = 'queued' | 'running' | 'cancel_requested' | 'succeeded' | 'failed' | 'cancelled' | 'timed_out';

type ReportJobView = {
  jobId: string;
  status: JobStatus;
  percent: number | null;
  phase: string;
  summary: string;
  canCancel: boolean;
  canRetry: boolean;
};

export default function ReportJobResource() {
  const { output, isLoading, isError, isCancelled } = useToolData<unknown, ReportJobView>();
  const callServerTool = useCallServerTool();
  const [job, setJob] = useState<ReportJobView | null>(null);
  const [isCancelling, setIsCancelling] = useState(false);

  useEffect(() => {
    if (output) setJob(output);
  }, [output]);

  useEffect(() => {
    if (!job || !['queued', 'running', 'cancel_requested'].includes(job.status)) return;

    const jobId = job.jobId;
    let active = true;
    let timer: number;

    async function poll() {
      try {
        const result = await callServerTool({
          name: 'get_report_status',
          arguments: { jobId },
        });

        if (active && !result.isError && result.structuredContent) {
          setJob(result.structuredContent as ReportJobView);
        }
      } catch {
        // Keep the last status; the next poll retries.
      } finally {
        if (active) timer = window.setTimeout(poll, 3000);
      }
    }

    timer = window.setTimeout(poll, 3000);
    return () => {
      active = false;
      window.clearTimeout(timer);
    };
  }, [callServerTool, job?.jobId, job?.status]);

  useTeardown(() => {
    // Stop timers, abort browser requests, and detach subscriptions here.
  });

  async function cancelJob() {
    if (!job || !job.canCancel) return;
    setIsCancelling(true);

    try {
      const result = await callServerTool({
        name: 'cancel_report',
        arguments: { jobId: job.jobId },
      });

      if (!result.isError) {
        setJob(result.structuredContent as ReportJobView);
      }
    } finally {
      setIsCancelling(false);
    }
  }

  if (isLoading) return <SafeArea className="p-4">Starting report...</SafeArea>;
  if (isCancelled) return <SafeArea className="p-4">Report start was stopped.</SafeArea>;
  if (isError || !job) return <SafeArea className="p-4">Report could not start.</SafeArea>;

  return (
    <SafeArea className="space-y-4 p-4">
      <header>
        <p className="text-sm opacity-70">{job.status}</p>
        <h1 className="text-lg font-semibold">{job.phase}</h1>
        <p>{job.summary}</p>
      </header>

      <progress value={job.percent ?? undefined} max={100} aria-label="Report progress" />

      {job.canCancel && (
        <button type="button" disabled={isCancelling} onClick={cancelJob}>
          {isCancelling ? 'Cancelling...' : 'Cancel'}
        </button>
      )}
    </SafeArea>
  );
}

This is the app pattern. The first model-visible tool gets the UI on screen. The resource polls an app-only status tool while the job is active. A timeout scheduled after each response prevents overlapping calls, and effect cleanup stops polling when the state becomes terminal or the iframe unmounts. In production, show an error and retry later if get_report_status fails.

For high-frequency updates, use a server-supported stream only when your host path, CSP, and network layer are designed for it. Polling every few seconds is easier to test and works in more places.

Where MCP Tasks Fit

MCP Tasks are a better fit when the work itself should be addressable as a protocol object instead of a private job record. The current extension defines a task handle, status, optional status message, result or error, polling, input requests, and cancellation. It does not define a numeric percent field. If a progress bar needs a percentage, fetch it from a product-specific job status tool; otherwise show a phase label or indeterminate indicator.

Both sides must support the extension. The client signals io.modelcontextprotocol/tasks in its per-request capabilities, and the server advertises support through server/discover. The server may then return resultType: "task" with a taskId, time-to-live, and suggested pollIntervalMs instead of a final tool result. The client calls tasks/get at that interval, which may change over time. Do not return a Task to a client that did not opt in. Check your target host’s support before choosing this path for a ChatGPT App or Claude Connector.

The protocol statuses are working, input_required, completed, failed, and cancelled. An input_required response includes requests that the client answers with tasks/update. A completed task carries the original result, while a failed task carries a JSON-RPC error. An ordinary tool result with isError: true can still arrive in a completed task, so inspect the result as well as the task status. The Tasks overview also describes optional notifications/tasks status updates for clients that subscribe to them. Polling remains the default.

Use MCP Tasks when:

  • The user may need to resume or inspect work outside the original app view.
  • Multiple clients or hosts need to understand the same long-running operation.
  • The workflow has a durable lifecycle beyond one request.
  • You want the protocol task handle to be the source of truth.

Use your own job tools when:

  • The target hosts do not support MCP Tasks yet.
  • You need a product-specific queue model.
  • The workflow already exists in your backend.
  • You need the smallest portable path across ChatGPT, Claude, and other MCP App hosts.

You can still design both with the same UI states. Whether the ID is jobId or taskId, the resource needs a clear status, result, failure, and cancellation path. Product-specific percent, phases, and retry controls can sit on top when the server provides them.

Cancellation Is Two Different Things

MCP App cancellation can mean two different events.

First, the host may cancel a tool call. In a rendered resource, this can surface as the app’s cancelled state. The right UI is neutral: the user stopped the operation before the initial result arrived.

Second, the user may cancel backend work after the job has started. That is a server operation. For a custom job, the UI calls cancel_report, the server marks the job as cancellation requested, and the worker checks that flag between steps. For an MCP Task, the client sends tasks/cancel. The server acknowledges the request, but cancellation is cooperative: the task can remain working or even complete if the work finishes first. Show “Cancelling” until a later status confirms the outcome.

Do not assume the first event automatically causes the second. If start_report already enqueued work and then the user stops the model, the queue job may still run unless your server links request cancellation to backend cancellation.

A practical cancellation contract looks like this:

  • cancel_report is idempotent.
  • Cancelling a finished job returns the finished status.
  • Cancelling a running job moves it to cancel_requested or cancelled.
  • Workers check cancellation between external API calls and after each batch.
  • The UI disables Cancel while the cancel request is in flight.
  • The model-visible summary says whether cancellation happened or was only requested.

What the Model Should See

Long-running job UIs can collect a lot of data. Keep most of it out of model context.

Good model-visible fields:

  • Job ID or task ID.
  • Status.
  • Current phase.
  • Percent, if known.
  • Short user-facing summary.
  • Final artifact title or result summary.
  • Next available actions.

Usually app-only fields:

  • Full logs.
  • Stack traces.
  • Internal queue names.
  • Worker IDs.
  • Temporary object storage URLs.
  • Trace IDs and span IDs.
  • Raw upstream API payloads.

The rule is simple: if the model needs it to answer the user’s next question, put it in structuredContent or model context. If the user needs it only for display, keep it in the app. If it is operational data for debugging, keep it out of both unless the user explicitly asks for a support view.

Test the Boring States

Long-running work fails in the states that demos skip.

Add simulations for:

  • queued with no percent yet.
  • running with percent.
  • running with unknown percent.
  • succeeded with a result artifact.
  • failed with a user-facing error.
  • cancelled after a user request.
  • cancel_requested while the worker is still stopping.
  • timed_out with a retry path.
  • stale job ID or missing permissions.

Then add interaction tests:

  • Polling stops during teardown.
  • Cancel is disabled during the cancel request.
  • A finished job cannot be cancelled twice.
  • Retry appears only when the server allows it.
  • Logs are truncated and do not enter model-visible output by default.
  • Final artifacts render with allowed CSP domains.

In sunpeak, create simulation files for each status and run the resource in the MCP App inspector. Use Playwright to click Cancel, Retry, Refresh, and final artifact links against the same host replica you use for normal ChatGPT App and Claude Connector testing.

The Build Order

Build long-running MCP App flows in this order:

  1. Define the status shape and allowed transitions.
  2. Implement start_*, get_*_status, cancel_*, and retry_* tools.
  3. Decide which fields are model-visible and which stay app-only.
  4. Add the resource with queued, running, done, failed, cancelled, and timed-out states.
  5. Add simulations for each status.
  6. Add Playwright tests for polling, cancel, retry, and teardown.
  7. Add progress notifications only if the active request window benefits from them.
  8. Add MCP Tasks if your target hosts support them and the workflow needs a standard task handle.

This order keeps the protocol contract clear before the UI grows around it. It also gives agents a stable map: one tool starts the job, app-only tools control it, and the resource owns the user’s progress view.

Ship It Without Guessing

Long-running tools are where MCP Apps start to feel like real product surfaces. Users need to see what is happening, stop work safely, recover after failure, and inspect the result without reading raw logs in chat.

Use progress notifications for active request progress. Use jobs or MCP Tasks for durable work. Use app-only tools for status, cancellation, retry, and final result fetches. Then test the states you hope users never see.

With sunpeak, you can model each job state as a simulation, render it locally in ChatGPT and Claude host replicas, and run the same flow in CI before you connect real accounts or start long backend jobs.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

How should an MCP App handle a long-running tool?

For short work, keep the tool call synchronous and return the final result. For work that may outlive a normal host timeout, return a job ID or task handle, render an MCP App resource that shows progress, and add app-only tools for polling, cancellation, retry, and fetching final artifacts.

What are MCP progress notifications?

MCP progress notifications are optional protocol notifications sent during a request when the client includes a progress token. They let the server report numeric progress, optional totals, and status messages while a request is still running. They are useful for active tool calls, but they are not a durable job system by themselves.

When should I use MCP Tasks instead of progress notifications?

Use MCP Tasks when both client and server support the Tasks extension and the operation needs a durable handle after the first request returns. Use progress notifications for an active request with a progress token. The current Tasks extension reports state through tasks/get or notifications/tasks; it does not use notifications/progress for tasks.

Should a ChatGPT App or Claude Connector keep a tool call open for minutes?

Usually no. Host, proxy, platform, and upstream timeouts can end long requests before your work finishes. If a workflow can take more than a short interaction, start the work, return a job ID or task handle, and let the MCP App UI poll or subscribe for updates.

How do I let users cancel long-running MCP App work?

Expose cancellation as an app-only server tool for a custom job or use tasks/cancel for an MCP Task. Acknowledge the request, then let the worker stop cooperatively. The task may still finish if the work completed first, so keep the UI in a cancelling state until the next status check confirms the outcome.

What should go in structuredContent for a long-running MCP App?

Put the stable job or task summary in structuredContent: ID, status, percent if known, current phase, user-facing summary, and next actions. Keep logs, trace IDs, internal queue names, temporary URLs, and raw worker payloads out of model-visible structuredContent unless the model truly needs them.

How do I test long-running MCP App tools?

Create simulation files for queued, running, succeeded, failed, cancelled, timed-out, and retry states. Add protocol tests for start, status, cancel, and result tools, then run Playwright tests in a local inspector to verify progress UI, polling cleanup, disabled buttons, and final artifact links.

Does an MCP Task include a numeric progress percentage?

No. The current Tasks extension provides status, an optional statusMessage, a suggested pollIntervalMs, and a final result or error. If users need a percentage, expose it through your own job status tool or another supported app data path and show an indeterminate indicator when no total is known.