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.
The recipe
Section titled “The recipe”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} />;}The server decides
Section titled “The server decides”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.
Detect the capability and degrade
Section titled “Detect the capability and degrade”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 });How a task ends
Section titled “How a task ends”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.
The dual-channel pattern
Section titled “The dual-channel pattern”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, anduseDataSyncre-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.
How it works
Section titled “How it works”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.
Related
Section titled “Related”- Call MCP tools with types: the inline
useCallToolfor work that fits in one request. - Keep the UI in sync with the agent: the server’s announcement that
useDataSyncre-reads on.