Long-Running MCP App Tools: Progress, Tasks, Polling, and Cancellation

Long-running MCP App tools need a progress contract, a durable job model, and UI states for running, failed, cancelled, and complete work.
The largest open search gap around MCP App keywords is long-running work.
Most posts explain how to render the first resource, pass structuredContent, handle loading states, or call a server tool from a button. Builders hit the next problem as soon as their 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 only while an active request is open and the host supports them. Return a job ID or task handle for anything that may outlive that request. Render progress in the MCP App resource, use app-only tools for status, cancel, retry, and result fetches, and test every state locally before you connect a real host.
The Decision Rule
Start with the shortest design that can tell the truth about the work.
| Work shape | Use | Why |
|---|---|---|
| Finishes quickly and returns one result | Normal synchronous tool call | The host can wait and render the final result |
| Takes a while but the request stays open | Tool call plus progress notifications | The server can report phases before returning |
| May outlive host, proxy, or platform timeouts | Start job plus status tool | The UI can keep checking a durable server record |
| Needs a standard durable handle across clients | MCP Tasks, when supported | The task lifecycle becomes part of the protocol contract |
| Runs after the user leaves the iframe | Server-side job, not a view tool | The 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 progress notification can include the current progress value, an optional total, and an optional message.
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.
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.
| Tool | Visibility | Job |
|---|---|---|
start_report | model and app | Creates the job, returns jobId, status, summary, and resource URI |
get_report_status | app | Returns the latest status, progress, phase, and short log tail |
cancel_report | app | Requests cancellation and returns the new status |
retry_report | app | Starts a retry for failed or cancelled jobs when allowed |
get_report_result | app or model | Returns 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' | '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' | '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'].includes(job.status)) return;
const interval = window.setInterval(async () => {
const result = await callServerTool({
name: 'get_report_status',
arguments: { jobId: job.jobId },
});
if (!result.isError) {
setJob(result.structuredContent as ReportJobView);
}
}, 3000);
return () => window.clearInterval(interval);
}, [callServerTool, job]);
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 ?? 0} 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. Teardown stops polling when the host removes the iframe.
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. A task can carry status, progress, results, cancellation, and follow-up operations through a standard extension path when the client and server support it.
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 status, percent or unknown progress, phase, result, failure, cancellation, retry, and timestamps.
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. The UI calls cancel_report, the server marks the job as cancellation requested, and the worker checks that flag between steps.
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_reportis idempotent.- Cancelling a finished job returns the finished status.
- Cancelling a running job moves it to
cancel_requestedorcancelled. - 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:
queuedwith no percent yet.runningwith percent.runningwith unknown percent.succeededwith a result artifact.failedwith a user-facing error.cancelledafter a user request.timed_outwith 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:
- Define the status shape and allowed transitions.
- Implement
start_*,get_*_status,cancel_*, andretry_*tools. - Decide which fields are model-visible and which stay app-only.
- Add the resource with queued, running, done, failed, cancelled, and timed-out states.
- Add simulations for each status.
- Add Playwright tests for polling, cancel, retry, and teardown.
- Add progress notifications only if the active request window benefits from them.
- 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
npx sunpeak newFurther Reading
- MCP App examples - includes the long-running job monitor pattern
- MCP App actions - callServerTool, sendMessage, and updateModelContext
- MCP App error handling - loading, error, and cancelled states
- MCP App lifecycle - input, results, cancellation, and teardown
- Fetching data in MCP Apps - polling and refresh patterns
- MCP App observability - production logs, metrics, and debugging
- MCP App framework - build portable ChatGPT Apps and Claude Connectors
- MCP App inspector - test job states locally
- MCP progress notifications - Model Context Protocol
- MCP Tasks extension - Model Context Protocol
- MCP Apps overview - Model Context Protocol
- OpenAI Apps SDK reference - ChatGPT app bridge fields
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 the work needs a durable handle, can continue independently, or needs status checks after the first request returns. Use progress notifications when the request is still active and the host supports displaying request-level progress. Many production MCP Apps use both: a task or job for durability, plus progress updates while the start request is still open.
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 or task operation, persist cancellation state on the server, and make the worker check that state between steps. Also handle host-side tool cancellation separately, because a user stopping the model does not always cancel backend work that already started.
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.