Durable MCP tools that coordinate by promise id in Python

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-35

The 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-112

The durable primitives in play

Resonate.remote() — class method that constructs a Resonate client backed by a RemoteStore and a Poller message source, so promises live on the Resonate Server and any worker in the same group can claim them. weather_data.py:9; SDK at resonate-sdk-py tag v0.6.7, resonate/resonate.py:194-256 (@classmethod def remote).

@resonate.register — wraps a callable as a Function and 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; SDK resonate/resonate.py:317-399.

weather_data.run(job_name, *args, **kwargs) — Function.run is return self.begin_run(id, *args, **kwargs).result() (SDK resonate/resonate.py:1391), so it creates (or attaches to) the durable promise identified by job_name, blocks until that promise resolves, and returns the function's return value (type R, not a Handle). Duplicate start_gathering calls with the same coordinates collapse onto one durable execution. weather_data.py:54; SDK resonate/resonate.py:1368-1391.

weather_data.begin_run(job_name, *args, **kwargs) — non-blocking sibling that returns a Handle[R] without waiting on the promise. Swap .run for .begin_run in start_gathering to make the tool return immediately after dispatch. SDK resonate/resonate.py:1393-1426.

resonate.get(job_name) — returns a Handle[Any] referencing the existing durable promise by id. Non-blocking; the handle is the read-side primitive. weather_data.py:80, 110; SDK resonate/resonate.py:715-755.

handle.done() — non-blocking; subscribes on first call, then reads the underlying Future state. Used by probe_status to differentiate "running" from a resolved status without waiting. SDK resonate/models/handle.py:26-28.

handle.result() — blocks on the Future until the durable promise resolves, then returns the function's return value. Used both by probe_status (after done() is True) and by await_result (unconditionally). SDK resonate/models/handle.py:30-32.

job_name as durable-promise id — the format f"weather_data_{latitude}_{longitude}_{year}_{month}" is what makes the three tools coordinate. A second start_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_data is fetching. The durable promise keyed by job_name is already on the Resonate Server. When the server process restarts, the local worker reclaims the in-flight promise and re-executes weather_data from 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-meteo returns an HTTP error. response.raise_for_status() raises (weather_data.py:34); the SDK catches the exception and retries weather_data per its retry policy. No try/except is needed inside the durable function.

The agent calls start_gathering twice for the same coordinates. Both calls produce the same job_name and 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_gathering and await_result. await_result calls resonate.get(job_name) (weather_data.py:110), which attaches a fresh Handle to the existing durable promise. The result is fetched from the server, not from in-memory state in the MCP server process.

probe_status is called before the work finishes. handle.done() is non-blocking and returns False, 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_status and await_result defensively json.loads a string job_names argument (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.run for Function.begin_run in start_gathering — the start / probe / await split 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.remote classmethod: 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#L317

Function.run (blocking): https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.py#L1368

Function.begin_run (non-blocking): https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.py#L1393

Resonate.get (read-side handle by id): https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.py#L715

Handle.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