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 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} />;
}

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.

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.

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-changed via useDataSync) 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.

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.