Skip to content

Handle long-running tools (tasks)

Some tool work does not fit in one request. A research run, a batch import, or a multi-stage analysis can take minutes, and a plain callTool dies at the stock MCP request timeout (~60s). You need to start the work, show progress, and pick up the result when it lands, possibly after the user has navigated away and back.

useCallToolAsTask<TInput, TOutput>(name) wraps the full task lifecycle. fire starts (or restarts) the call and resolves as soon as the server answers it, task carries the live status, result and error populate when the work reaches a terminal state, isWorking and isTerminal are derived flags for rendering, and cancel stops an in-flight task. Wrap your tree in <AppProvider>.

import { useCallToolAsTask } from "@nimblebrain/synapse/react";
function ResearchPanel() {
const { fire, task, result, error, isWorking, isTerminal, cancel } =
useCallToolAsTask<{ query: string }, { report: string }>("start_research");
if (!task) return <button onClick={() => fire({ query: "Q2 metrics" })}>Run</button>;
if (isWorking) return <Spinner status={task.status} onCancel={cancel} />;
if (error) return <ErrorBox error={error} />;
return <Report data={result} />;
}

callToolAsTask speaks the MCP tasks extension (io.modelcontextprotocol/tasks, protocol 2026-07-28). It declares the extension in the tools/call request’s _meta, and the server chooses per call: it runs the tool outright and answers with the result, or it answers with a task the handle then polls. Both come back as a handle, so your code has one path. A call the server answered outright gives a handle whose task is already completed and whose result() resolves at once.

So a server with no tasks support still works: every call is answered outright. To have a slow tool run as a task, the server has to implement the tasks extension; see your server SDK’s documentation for it.

callToolAsTask needs the host to relay the extension. A host that does declares hostCapabilities.experimental["io.modelcontextprotocol/tasks"] (an empty object: presence is the signal). On a host that does not, callToolAsTask rejects with HostCapabilityError without sending. Read app.supportsTasks to branch before you fire, and fall back to a blocking callTool:

import { callToolAsTask } from "@nimblebrain/synapse";
const result = app.supportsTasks
? await (await callToolAsTask(app, "start_research", { query })).result()
: await app.callTool("start_research", { query });

handle.result() resolves with the tool’s result, parsed the way callTool parses one, when the task completes. It rejects with a TaskError when the task fails (its message and code are the server’s error), is cancelled, or asks for input (input_required). Synapse cannot answer an input request, so it cancels such a task before rejecting. TaskError.task.status says which. The hook puts the same error in error.

When a task creates a domain entity (a research run, an import job), the UI learns the entity exists from the server, not from the task result. Keep the two channels separate:

  • The task channel (task, isWorking, isTerminal, result) signals lifecycle: started, running, done, cancelled.
  • The data channel is the server’s own: it announces the write with notifications/resources/list_changed, and useDataSync re-reads the list the new entity now appears in.

So a UI that needs to navigate to the entity the task just created should re-read on useDataSync rather than waiting on result.

When the server answers with a task, the handle polls tasks/get at the task’s pollInterval (2 s when it names none, never faster than every 250 ms). A terminal tasks/get carries the outcome inline: the result when completed, the error when failed. onStatus and the hook’s task are fed by those polls; the host pushes no status of its own. A failed poll is retried: result() rejects only after three in a row, or at once when the host no longer knows the task (-32602).

Two things to know about cancellation: cancel sends tasks/cancel and sets error only when that request fails, and unmounting the component, or firing again, stops polling but does not cancel the server-side task. It keeps running until it ends or its ttl lapses. With the handle directly, pass result({ signal }) an AbortSignal to stop waiting.