Problem
synapse.callTool() (and the useCallTool hook) awaits the terminal CallToolResult for every tool invocation. For tools that declare execution.taskSupport: "optional" | "required" per the MCP 2025-11-25 draft tasks utility, that means the returned Promise doesn't resolve until the server-side task reaches a terminal state (result or error).
For a long-running task (e.g. deep research that takes 30 s–3 min), every UI consumer is forced to wait that long before it can react. The Promise contract hides the task-id and interim status events that the underlying MCP protocol already provides.
Observed
In nimblebraininc/synapse-research, a "Retry with same query" button in the detail view calls:
const result = await synapse.callTool<
{ query: string },
{ run_id: string; status: string }
>("start_research", { query });
if (!result.isError && result.data?.run_id) {
navigate({ view: "detail", id: result.data.run_id });
}
start_research is task-augmented. Platform logs show the tool fires immediately and the server-side entity is created within ~100 ms (list_research_runs refreshes start flowing right after):
[api] tools/call server=synapse-research tool=start_research identity=user_…
[api] tools/call server=synapse-research tool=list_research_runs … ← x many
But the await above doesn't resolve until the full research run completes (minutes). The button stays in its pending state; the user sees nothing happen and navigates away. When it finally resolves, the page they were on is long gone.
We had to fall back to firing the tool call and navigating to the list view, losing the natural "retry → land on the new run's detail page" UX.
Why it matters
MCP's task utility exists precisely to avoid request/response blocking for long-running work. CreateTaskResult carries a taskId in <1 s — the SDK is currently throwing that signal away. Any UI feature that wants to:
- Navigate to a newly-created resource right after firing a task-augmented tool
- Show a "run started" confirmation without waiting for completion
- Render intermediate status messages in real time (separate from
useDataSync's entity broadcasts)
- Cancel a task via
tasks/cancel before it completes
…is blocked on the same one-Promise-to-rule-them-all surface.
Proposed API shape (pick whichever fits)
Option A — new method, split promises
const handle = await synapse.callToolAsTask("start_research", { query });
// handle.task: CreateTaskResult — resolved already (<1 s)
// handle.result: Promise<CallToolResult> — resolves on terminal
// handle.cancel(): Promise<void> — sends tasks/cancel
// handle.onStatus(cb) — subscribe to taskStatus events
Option B — enhance existing callTool with task-aware option
const { task, result, cancel } = await synapse.callTool("start_research", { query }, { asTask: true });
// task: CreateTaskResult, result: Promise<...>, cancel: () => void
Option C — new React hook
const { fire, task, result, status, cancel, isPending } = useCallToolAsTask("start_research");
fire({ query: "..." });
// `task` populates in <1 s; `result` populates on terminal; `status` streams
Behaviour for non-task-augmented tools
A client opting in to the task-aware API against a plain tool should either:
- Return immediately with a synthetic "task" that's already complete, or
- Throw / warn that the tool doesn't advertise
taskSupport
Pick whichever matches the SDK's ergonomic norms.
Scope / compatibility
Purely additive. Existing callTool / useCallTool behaviour stays as-is. Callers who want the new semantics opt in by reaching for the task-aware surface.
Related
Problem
synapse.callTool()(and theuseCallToolhook) awaits the terminalCallToolResultfor every tool invocation. For tools that declareexecution.taskSupport: "optional" | "required"per the MCP 2025-11-25 drafttasksutility, that means the returned Promise doesn't resolve until the server-side task reaches a terminal state (resultorerror).For a long-running task (e.g. deep research that takes 30 s–3 min), every UI consumer is forced to wait that long before it can react. The Promise contract hides the task-id and interim status events that the underlying MCP protocol already provides.
Observed
In
nimblebraininc/synapse-research, a "Retry with same query" button in the detail view calls:start_researchis task-augmented. Platform logs show the tool fires immediately and the server-side entity is created within ~100 ms (list_research_runsrefreshes start flowing right after):But the
awaitabove doesn't resolve until the full research run completes (minutes). The button stays in its pending state; the user sees nothing happen and navigates away. When it finally resolves, the page they were on is long gone.We had to fall back to firing the tool call and navigating to the list view, losing the natural "retry → land on the new run's detail page" UX.
Why it matters
MCP's task utility exists precisely to avoid request/response blocking for long-running work.
CreateTaskResultcarries ataskIdin <1 s — the SDK is currently throwing that signal away. Any UI feature that wants to:useDataSync's entity broadcasts)tasks/cancelbefore it completes…is blocked on the same one-Promise-to-rule-them-all surface.
Proposed API shape (pick whichever fits)
Option A — new method, split promises
Option B — enhance existing
callToolwith task-aware optionOption C — new React hook
Behaviour for non-task-augmented tools
A client opting in to the task-aware API against a plain tool should either:
taskSupportPick whichever matches the SDK's ergonomic norms.
Scope / compatibility
Purely additive. Existing
callTool/useCallToolbehaviour stays as-is. Callers who want the new semantics opt in by reaching for the task-aware surface.Related
synapse-researchretry flow (PR/commit to be linked once repo is public)tasksutility: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/tasks.mdMcpSource.callToolAsTaskinNimbleBrainInc/nimblebrainstreamstaskCreated → taskStatus* → result|errorand could serve as a reference for what the SDK should surface to UI consumers