Skip to content

Task-aware callTool API: return CreateTaskResult early for task-augmented tools #3

@mgoldsborough

Description

@mgoldsborough

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

  • Reference consumer: synapse-research retry flow (PR/commit to be linked once repo is public)
  • MCP draft 2025-11-25 tasks utility: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/tasks.md
  • Platform-side client already handles this correctly: McpSource.callToolAsTask in NimbleBrainInc/nimblebrain streams taskCreated → taskStatus* → result|error and could serve as a reference for what the SDK should surface to UI consumers

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions