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.
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.
Python: resonate-sdk v0.6.x against the legacy Resonate Server. Rust: 0.4.0, in active development. TypeScript SDK example repo is forthcoming.
MCP server with three tools backed by a durable weather-data workflow.
rmcp-based MCP server with a get_forecast tool backed by a durable Resonate workflow.
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.
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()use resonate::prelude::*;
#[resonate::function]
async fn get_forecast(ctx: &Context, lat: f64, lon: f64) -> Result<String> {
// ctx.run checkpoints the HTTP call — a retry of the MCP tool with the
// same lat/lon reconnects to this workflow rather than re-fetching.
let json = ctx.run(fetch_nws, (lat, lon)).await?;
Ok(json)
}
#[resonate::function]
async fn fetch_nws(lat: f64, lon: f64) -> Result<String> {
let url = format!("https://api.weather.gov/points/{lat},{lon}");
// ...HTTP call returning the response body.
}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.
@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}#[tool_router(server_handler)]
impl WeatherBridge {
#[tool(description = "Get the forecast for the given lat/lon.")]
async fn get_forecast(&self, Parameters(p): Parameters<ForecastArgs>) -> Result<CallToolResult> {
let id = format!("forecast-{}-{}", p.lat, p.lon);
// Same id reconnects to an in-flight execution rather than re-fetching.
let result: String = self.resonate
.rpc(&id, "get_forecast", (p.lat, p.lon))
.target("poll://any@workers")
.await?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}
}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):
git clone https://github.com/resonatehq-examples/example-async-tools-mcp-server-py
cd example-async-tools-mcp-server-py
uv syncbrew install resonatehq/tap/resonate
resonate serveuv run python weather_data.pygit clone https://github.com/resonatehq-examples/example-mcp-tools-rs
cd example-mcp-tools-rs
cargo buildbrew install resonatehq/tap/resonate
resonate devcargo run --bin workerThe MCP server (weather-server binary) is launched by Claude Desktop directly via stdio — see the config below.
Configure Claude Desktop to launch the server. Add to claude_desktop_config.json (the Developers tab in Claude's settings shows the path):
{
"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.
Related#
- Async HTTP API endpoints — the same fire-and-forget shape, accessed via REST instead of MCP.
- Human-in-the-loop — pausing a workflow on external resolution.