LLM tool calls over MCP are synchronous and have no built-in retry, deduplication, or crash recovery, so a tool that does long-running work loses progress on failure and is hard to make idempotent across retries. Resonate splits the work into a single durable-promise-backed function plus three MCP tools — start_gathering, probe_status, await_result — that all key off a job_name that doubles as the promise id, so concurrent and repeat calls collapse onto one execution and the result is retrievable from any session by the same id. This example wires a FastMCP server to a Resonate worker so historical weather lookups against archive-api.open-meteo.com are durable, deduplicated by job name, and resumable across worker restarts. At the pinned SDK version Function.run is blocking; the non-blocking swap is covered below.
The shape of the solution
The durable function is a plain Python function registered with Resonate. It is what the MCP tools dispatch onto and read back from:
mcp = FastMCP("timer")
resonate = Resonate.remote()
@resonate.register
def weather_data(ctx, latitude, longitude, year, month, timezone="America/Edmonton"):
print(f"Weather data gathering started for {latitude}, {longitude} in {year}-{month} ({timezone})")
year = int(year)
month = int(month)
longitude = float(longitude)
latitude = float(latitude)
start_date = f"{year}-{month:02d}-01"
end_day = calendar.monthrange(year, month)[1]
end_date = f"{year}-{month:02d}-{end_day}"
timezone = "America/Edmonton"
url = "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 = requests.get(url, params=params)
response.raise_for_status()
return response.json()
# from example-async-tools-mcp-server-py/weather_data.py:8-35The three MCP tools all derive the same job_name from their arguments. start_gathering dispatches the durable function; probe_status iterates the requested ids and returns "running" or the resolved value; await_result blocks on each id until it resolves:
@mcp.tool()
def start_gathering(latitude, longitude, year, month, timezone="America/Edmonton"):
job_name = f"weather_data_{latitude}_{longitude}_{year}_{month}"
_ = weather_data.run(job_name, latitude, longitude, year, month)
return {"job_name": job_name}
@mcp.tool()
def probe_status(job_names):
if isinstance(job_names, str):
try:
job_names = json.loads(job_names)
except json.JSONDecodeError:
return {"error": "Invalid JSON for job_names"}
statuses = []
for job_name in job_names:
if isinstance(job_name, dict):
job_name = job_name.get("job_name", "")
handle = resonate.get(job_name)
if not handle.done():
statuses.append({"job_name": job_name, "status": "running"})
else:
statuses.append({"job_name": job_name, "status": handle.result()})
return statuses
@mcp.tool()
def await_result(job_names):
# ... same json.loads guard as probe_status ...
results = {}
for job_name in job_names:
if isinstance(job_name, dict):
job_name = job_name.get("job_name", "")
handle = resonate.get(job_name)
results[job_name] = handle.result()
return results
# from example-async-tools-mcp-server-py/weather_data.py:38-112The durable primitives in play
Resonate.remote()— class method that constructs aResonateclient backed by aRemoteStoreand aPollermessage source, so promises live on the Resonate Server and any worker in the same group can claim them.weather_data.py:9; SDK atresonate-sdk-pytagv0.6.7,resonate/resonate.py:194-256(@classmethod def remote).@resonate.register— wraps a callable as aFunctionand adds it to the registry under its name. The registered function is now invokable by name from anywhere talking to the same server.weather_data.py:12; SDKresonate/resonate.py:317-399.weather_data.run(job_name, *args, **kwargs)—Function.runisreturn self.begin_run(id, *args, **kwargs).result()(SDKresonate/resonate.py:1391), so it creates (or attaches to) the durable promise identified byjob_name, blocks until that promise resolves, and returns the function's return value (typeR, not aHandle). Duplicatestart_gatheringcalls with the same coordinates collapse onto one durable execution.weather_data.py:54; SDKresonate/resonate.py:1368-1391.weather_data.begin_run(job_name, *args, **kwargs)— non-blocking sibling that returns aHandle[R]without waiting on the promise. Swap.runfor.begin_runinstart_gatheringto make the tool return immediately after dispatch. SDKresonate/resonate.py:1393-1426.resonate.get(job_name)— returns aHandle[Any]referencing the existing durable promise by id. Non-blocking; the handle is the read-side primitive.weather_data.py:80, 110; SDKresonate/resonate.py:715-755.handle.done()— non-blocking; subscribes on first call, then reads the underlyingFuturestate. Used byprobe_statusto differentiate"running"from a resolved status without waiting. SDKresonate/models/handle.py:26-28.handle.result()— blocks on theFutureuntil the durable promise resolves, then returns the function's return value. Used both byprobe_status(afterdone()isTrue) and byawait_result(unconditionally). SDKresonate/models/handle.py:30-32.job_nameas durable-promise id — the formatf"weather_data_{latitude}_{longitude}_{year}_{month}"is what makes the three tools coordinate. A secondstart_gathering(...)with the same arguments produces the same id, attaches to the existing promise, and never re-fetches.weather_data.py:53.
What the SDK handles vs. what you write
You write four things: the FastMCP server (weather_data.py:8), one Resonate-registered function that does the actual work (weather_data.py:12-35), the three MCP tool wrappers that translate between MCP arguments and a durable-promise id (weather_data.py:38-112), and a stdio↔streamable-http proxy so Claude Desktop can connect (proxy.py). Inside weather_data there is no retry wrapper, no idempotency key construction, no status-table schema, and no manual progress tracking — the function is plain Python that calls requests.get and returns the response JSON.
The SDK does the rest: it persists the durable promise rooted at job_name to the Resonate Server before the function runs, claims the work onto a worker in the local group, retries the function automatically on raised exceptions (response.raise_for_status() errors become retries), resolves the promise on return, makes the same id retrievable by resonate.get(job_name) from any later tool call, deduplicates concurrent start_gathering calls with identical coordinates onto a single execution, and resumes the in-flight promise on a different worker if the original crashes.
One nuance on this repo's pinned SDK (resonate-sdk>=0.6.7, pyproject.toml:9): start_gathering calls weather_data.run(...), which at v0.6.7 is self.begin_run(id, *args, **kwargs).result() (resonate/resonate.py:1391) — so the tool currently blocks until weather_data resolves before returning {"job_name": job_name}. The polling shape (call start_gathering, then poll probe_status, then commit to await_result) only becomes truly non-blocking once start_gathering is changed to weather_data.begin_run(...) (returns a Handle without waiting; resonate/resonate.py:1393-1426). The durability, deduplication, retry, and cross-session retrievability claims hold either way; only the first-call latency differs.
Failure modes covered
- The MCP server process crashes while
weather_datais fetching. The durable promise keyed byjob_nameis already on the Resonate Server. When the server process restarts, the local worker reclaims the in-flight promise and re-executesweather_datafrom the top — there is no intra-function checkpoint, so the HTTP fetch is repeated, and the promise resolves on the new run.weather_data.py:33-35. open-meteoreturns an HTTP error.response.raise_for_status()raises (weather_data.py:34); the SDK catches the exception and retriesweather_dataper its retry policy. Notry/exceptis needed inside the durable function.- The agent calls
start_gatheringtwice for the same coordinates. Both calls produce the samejob_nameand therefore the same durable-promise id (weather_data.py:53). The SDK collapses the second call onto the in-flight promise — no second HTTP fetch. - The agent's session restarts between
start_gatheringandawait_result.await_resultcallsresonate.get(job_name)(weather_data.py:110), which attaches a freshHandleto the existing durable promise. The result is fetched from the server, not from in-memory state in the MCP server process. probe_statusis called before the work finishes.handle.done()is non-blocking and returnsFalse, so the tool returns{"status": "running"}rather than hanging the agent (weather_data.py:81-82). The same id can then be polled or awaited later.- MCP clients pass a tool argument as a JSON-encoded string instead of an array. Both
probe_statusandawait_resultdefensivelyjson.loadsa stringjob_namesargument (weather_data.py:69-73,:99-103) before iterating — a transport-level quirk unrelated to durability, but called out because it lives in the tool code.
When to reach for this pattern
- If multiple MCP tools should coordinate on a shared piece of background work and you can derive a stable id from the tool arguments — the durable-promise id is the coordination point.
- If you want re-invocations of the same tool with the same arguments to deduplicate onto a single execution without writing a status table.
- If the MCP server process is allowed to restart (deploy, OOM, host reboot) and the agent's in-flight work must survive.
- If the agent's session may restart between dispatch and result, and you want the result retrievable by id from a later session.
- If you intend to scale out by running multiple MCP server processes against the same Resonate Server —
Resonate.remote()already routes by group, and any worker can claim any unfinished promise. - If you want the first tool call to return immediately (non-blocking dispatch), swap
Function.runforFunction.begin_runinstart_gathering— thestart/probe/awaitsplit then maps to a poll-then-block agent loop.
Sources
- Example repo: https://github.com/resonatehq-examples/example-async-tools-mcp-server-py
- Resonate Python SDK: https://github.com/resonatehq/resonate-sdk-py
- SDK version pinned:
resonate-sdk>=0.6.7(pyproject.toml:9). Resonate.remoteclassmethod: https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.py#L194@resonate.register: https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.py#L317Function.run(blocking): https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.py#L1368Function.begin_run(non-blocking): https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.py#L1393Resonate.get(read-side handle by id): https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.py#L715Handle.done/Handle.result: https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/models/handle.py- README — async tools as MCP pattern: https://github.com/resonatehq-examples/example-async-tools-mcp-server-py/blob/main/README.md
