Async MCP tools backed by durable workflows

Convert synchronous MCP tool calls into durable async jobs an LLM can fire-and-forget then poll for results.

Async Agent Tools banner

A FastMCP server exposes three tools — start, probe, await — backed by a durable Resonate workflow. The LLM kicks off long jobs, polls them, and collects results without blocking on the work.

SDK versions

Python: resonate-sdk v0.6.x against the legacy Resonate Server. Rust: 0.4.0, in active development. TypeScript SDK example repo is forthcoming.

The problem#

MCP standardizes tool calling but not what happens when a tool takes minutes, hits a flaky upstream, or runs while the agent's MCP host crashes. Every tool implementation reinvents timeouts, retries, idempotency, and crash recovery — and most get at least one wrong.

Resonate's solution#

Split a tool into three smaller MCP tools — start_gathering, probe_status, await_result — and back them with a durable Resonate function. start_gathering returns a promise ID immediately, probe_status reports without blocking, await_result blocks the agent only when it actually wants the answer. Crashes don't lose progress and the same job ID deduplicates retries.

Code walkthrough#

The server exposes one durable function (weather_data) and three thin MCP tools that wrap Resonate's promise APIs.

The durable function#

The MCP tool dispatches a Resonate workflow that does the actual side-effecting work. Whatever the tool does — call an external API, stream rows from a DB, transform a payload — happens here, with retries and crash recovery wrapped around it.

weather_data.py — the durable function
from resonate import Resonate
from fastmcp import FastMCP
import requests, calendar

mcp = FastMCP("timer")
resonate = Resonate.remote()


@resonate.register
def weather_data(ctx, latitude, longitude, year, month, timezone="America/Edmonton"):
    start_date = f"{year}-{int(month):02d}-01"
    end_day = calendar.monthrange(int(year), int(month))[1]
    end_date = f"{year}-{int(month):02d}-{end_day}"

    response = requests.get(
        "https://archive-api.open-meteo.com/v1/archive",
        params={
            "latitude": latitude, "longitude": longitude,
            "start_date": start_date, "end_date": end_date,
            "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
            "timezone": timezone,
        },
    )
    response.raise_for_status()
    return response.json()

The three async MCP tools#

Each tool is a thin shell around a Resonate API call: kick off, poll, await (Python). The Rust port collapses the same pattern into a single tool that dispatches via resonate.rpc(...) and awaits a deterministic-ID promise — same dedup, fewer tools.

weather_data.py — the MCP tools
@mcp.tool()
def start_gathering(latitude, longitude, year, month):
    """Start a weather data gathering job."""
    job_name = f"weather_data_{latitude}_{longitude}_{year}_{month}"
    # Fire-and-forget: dispatch the workflow, return the ID immediately.
    _ = weather_data.run(job_name, latitude, longitude, year, month)
    return {"job_name": job_name}


@mcp.tool()
def probe_status(job_names):
    """Probe one or more jobs without blocking."""
    statuses = []
    for job_name in job_names:
        handle = resonate.get(job_name)
        statuses.append({
            "job_name": job_name,
            "status": "running" if not handle.done() else handle.result(),
        })
    return statuses


@mcp.tool()
def await_result(job_names):
    """Block until a job completes and return its result."""
    return {n: resonate.get(n).result() for n in job_names}

The same job/promise ID is the deduplication key: if the agent retries with the same arguments, Resonate reconnects to the in-flight execution rather than running the work twice.

Run it locally#

You need a Resonate Server, the MCP server, and Claude Desktop (or any MCP client) configured to load the server.

Clone the repo and install dependencies (uses uv):

code
git clone https://github.com/resonatehq-examples/example-async-tools-mcp-server-py
cd example-async-tools-mcp-server-py
uv sync
Terminal 1 — Resonate Server (legacy)
brew install resonatehq/tap/resonate
resonate serve
Terminal 2 — MCP server
uv run python weather_data.py

Configure Claude Desktop to launch the server. Add to claude_desktop_config.json (the Developers tab in Claude's settings shows the path):

code
{
  "mcpServers": {
    "weather": {
      "command": "/opt/homebrew/bin/uv",
      "args": [
        "--directory", "ABSOLUTE/PATH/TO/example-async-tools-mcp-server-py",
        "run", "proxy.py"
      ]
    }
  }
}

Restart Claude Desktop. The three tools (start_gathering, probe_status, await_result) will appear in the MCP server panel. Ask Claude to gather historic weather data for a few cities — it will start jobs, poll their status, and collect results without blocking on each gather.

Try the dedup story#

Ask Claude to gather data for the same city twice. The second start_gathering call returns the same job_name, and Resonate's promise dedup means the underlying API call doesn't fire twice. This is the same primitive that makes safe LLM retries free.