3 min readResonate HQJust published

Async MCP tool backed by a durable background job in Python

How an MCP server returns a promise id instead of blocking, and lets the agent ask back later — backed by a Resonate-registered function and a durable sleep.

Resonate brand card on a dark background with a plum spectrum wave at the bottom and the post headline in white Sansation.

LLM tool calls have a short attention budget — if a tool blocks for thirty seconds the agent times out or stalls the chat. The Resonate shape splits the tool into two MCP tools over one durable promise: set_timer schedules a registered function and returns the promise id immediately; get_timer_status looks up that promise later and reports running or the final result. The example shows the smallest version of this pattern: a timer whose durable step is ctx.sleep, served as an MCP tool to Claude Desktop through a stdio→streamable-http proxy.

The shape of the solution

from fastmcp import FastMCP
from resonate import Resonate
 
mcp = FastMCP("timer")
resonate = Resonate.remote()
 
 
@resonate.register
def timer(ctx, timer_name, seconds):
    print(f"Timer started: {timer_name} for {seconds} seconds")
    yield ctx.sleep(int(seconds))
    return "complete"
 
 
@mcp.tool()
def set_timer(timer_name, seconds):
    # ...docstring...
    _ = timer.run(timer_name, timer_name, seconds)
    return {"promise_id": timer_name}
 
 
@mcp.tool()
def get_timer_status(timer_name):
    # ...docstring...
    if isinstance(timer_name, dict):
        timer_name = timer_name.get("timer_name", "")
    promise_id = f"{timer_name}"
    handle = resonate.get(promise_id)
    if not handle.done():
        return {"status": "running"}
    return {"status": handle.result()}
 
 
def main():
    mcp.run(transport='streamable-http', host='127.0.0.1', port=5001)
# from example-agent-tool-background-job/timer.py:1-53

A second process is the stdio↔HTTP shim that Claude Desktop talks to:

from fastmcp import FastMCP
 
proxy = FastMCP.as_proxy(
    "http://localhost:5001/mcp",
    name="Remote Server Proxy"
)
 
if __name__ == "__main__":
    proxy.run()  # Runs via STDIO for Claude Desktop
# from example-agent-tool-background-job/proxy.py:1-11

The durable primitives in play

  • Resonate.remote() — constructs a Resonate client backed by a remote store and a poller for messages; all durable state lives on the Resonate server, not in the MCP process. timer.py:5; SDK resonate/resonate.py:106-129.
  • @resonate.register — registers timer as a durable function under its function name. timer.py:8; SDK resonate/resonate.py:168-199.
  • Function.run(id, *args) — creates (or attaches to) a durable promise with the given id and schedules the registered function; returns a Handle without waiting for the function to finish. timer.py:28; SDK resonate/resonate.py:217-231 and 561-571.
  • ctx.sleep(seconds) — yields an RFC(Sleep(...)) that is checkpointed on the server. The sleep is not time.sleep; the server schedules a callback and resumes the coroutine when the deadline is reached, even across worker restarts. timer.py:11; SDK resonate/resonate.py:426-427.
  • resonate.get(id) — looks up an existing durable promise by id and returns a Handle. timer.py:46; SDK resonate/resonate.py:269-274.
  • Handle.done() / Handle.result() — non-blocking completion check and blocking result fetch on the durable promise. timer.py:47-49; SDK resonate/models/handle.py:13-17.

The promise id chosen by the example is the caller-supplied timer_name (timer.py:28, 45). That id is the join key between set_timer and get_timer_status, and the idempotency key — invoking set_timer twice with the same name attaches to the same durable promise rather than starting a second timer.

What the SDK handles vs. what you write

You write: the registered generator function (timer), the two MCP tools that translate agent calls into Function.run and Resonate.get, and the promise-id naming scheme (here, timer_name).

The SDK handles: persisting the durable promise on the Resonate server, scheduling the sleep callback so the server resumes the function when the deadline elapses, returning a non-blocking Handle from run, re-attaching Handles on later lookups via Resonate.get, and replaying the function from its last checkpoint if the worker process restarts. The MCP server process itself is stateless with respect to in-flight timers — all timer state is on the Resonate server.

Failure modes covered

  • The MCP server crashes while a timer is sleeping. The durable promise and its sleep callback are on the Resonate server (Resonate.remote()timer.py:5). When the server process comes back up and registers timer again, the server delivers the resume message and the coroutine continues past yield ctx.sleep(...) (timer.py:11).
  • The agent calls set_timer twice with the same name. Both calls use timer_name as the durable promise id (timer.py:28). The second call attaches to the existing promise; only one timer runs, and get_timer_status returns the same state to both callers.
  • The agent asks for status before the timer finishes. Handle.done() returns False and the tool returns {"status": "running"} without blocking the MCP request (timer.py:47-48).
  • The agent asks for status after the timer finishes. Handle.done() returns True and Handle.result() returns "complete" (timer.py:49, matching the timer return value at timer.py:12).
  • The MCP client passes the argument as a dict instead of a string. The defensive shim at timer.py:43-44 unwraps {"timer_name": ...}. This is application-level, not SDK-level — different MCP clients serialise tool arguments differently.

When to reach for this pattern

  • If you are exposing a job to an LLM agent and the job's wall-clock time exceeds the agent's per-tool timeout.
  • If the agent needs to start a job in one tool call and check on it across later turns without holding a connection open.
  • If you want the agent to be able to reference the job by a stable, human-meaningful id (here, the timer name) and have repeated set_* calls be idempotent.
  • If the job contains a long wait (ctx.sleep, waiting on an external promise, a scheduled callback) where blocking the MCP transport would cause the agent to abandon the call.
  • If you need the job to survive a restart of the MCP server process between scheduling and completion.

Sources

  • Example repo: https://github.com/resonatehq-examples/example-agent-tool-background-job
  • Resonate Python SDK: https://github.com/resonatehq/resonate-sdk-py (tag v0.5.4 — pinned by this example)
  • Resonate.remote, Resonate.run, Resonate.get, Function.run, ctx.sleep: https://github.com/resonatehq/resonate-sdk-py/blob/v0.5.4/resonate/resonate.py
  • Handle.done / Handle.result: https://github.com/resonatehq/resonate-sdk-py/blob/v0.5.4/resonate/models/handle.py
  • FastMCP (proxy + tool registration): https://github.com/jlowin/fastmcp