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 task and returns immediately, 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. It is a SynapseProvider hook.
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} />;}Declare the tool task-aware on the server
Section titled “Declare the tool task-aware on the server”The tool has to opt in. Its tools/list entry declares
execution.taskSupport: "optional" (or "required"). With FastMCP (Python):
from fastmcp.server.tasks import TaskConfig
@mcp.tool(task=TaskConfig(mode="optional"))async def start_research(query: str, ctx: Context) -> dict: ...mode="optional" lets the same tool run inline (callTool) or as a task
(callToolAsTask), so the client decides per call. mode="required" rejects
non-task calls with JSON-RPC -32601.
Detect the capability and degrade
Section titled “Detect the capability and degrade”Not every host supports tasks. Hosts that do not will not advertise the
tasks.requests.tools.call capability, and callToolAsTask throws on them. Wrap
it and fall back to a blocking callTool if you want graceful degradation:
try { const handle = await synapse.callToolAsTask("start_research", { query }); // …task-aware UI} catch (err) { if (String(err).includes("tasks.requests.tools.call")) { // Legacy host: fall back to a blocking call. const result = await synapse.callTool("start_research", { query }); } else { throw err; }}To branch before you fire, read synapse._hostTasksCapability: it is null
before the handshake, undefined if the host did not advertise tasks, or the
capability shape if it did.
The dual-channel pattern
Section titled “The dual-channel pattern”When a task creates a domain entity (a research run, an import job), the entity ID arrives on the data-changed channel, not in the task result. Keep the two channels separate:
- The task channel (
task,isWorking,isTerminal,result) signals lifecycle: started, running, done, cancelled. - The entity channel (
synapse/data-changedviauseDataSync) carries the durable record, including the new entity’s ID.
So a UI that needs to navigate to the entity the task just created should listen
on useDataSync rather than waiting on result.
How it works
Section titled “How it works”callToolAsTask sends a task-augmented tools/call and gets back a task handle
immediately; the real result is fetched with tasks/result once the task reaches
a terminal status. Status notifications are optional per spec, so the hook also
polls tasks/get as a fallback and stops automatically on a terminal status. Two
things to know about cancellation: cancel issues tasks/cancel, and unmounting
the component does not cancel the server-side task. It keeps running, so the
user can re-fire to recover state on their next visit.
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 data-changed channel the entity ID arrives on.