Durable MCP tool handler in Rust on Resonate

An LLM client invoking a tool over MCP expects a single result, but the tool body may need to make multiple external HTTP calls that can each fail transiently, and the process serving stdio can be restarted at any time by the client. Resonate gives the tool body a workflow context whose intermediate steps are checkpointed against a remote server, so the MCP handler can dispatch via RPC and return the same result whether the workflow is in flight, already resolved, or recovering from a worker crash. This example exposes a get_forecast MCP tool that runs a two-step National Weather Service workflow on a separate worker process, with the MCP server itself holding no state.

The shape of the solution

#[resonate::function]
async fn get_forecast(ctx: &Context, latitude: f64, longitude: f64) -> Result<String> {
// Step 1 — resolve the lat/lon to a forecast endpoint via the NWS points API.
let points_url = format!("https://api.weather.gov/points/{latitude},{longitude}");
let points: serde_json::Value = ctx.run(fetch_nws, points_url).await?;

// Pull the forecast URL out of the points response. Missing field → Error::Application.
let forecast_url = points
.get("properties")
.and_then(|p| p.get("forecast"))
.and_then(|f| f.as_str())
.ok_or_else(|| Error::Application {
message: "NWS response missing properties.forecast".into(),
})?
.to_string();

// Step 2 — durable sleep. Survives crashes; resumes on the same wall clock.
ctx.sleep(std::time::Duration::from_millis(500)).await?;

// Step 3 — fetch the actual forecast.
let forecast: serde_json::Value = ctx.run(fetch_nws, forecast_url).await?;

// Step 4 — format the first five periods for the LLM.
// ...
Ok(formatted.join("\n\n---\n\n"))
}
// from example-mcp-tools-rs/src/bin/worker.rs:10-57

The MCP server side never sees the workflow body; it dispatches into it by promise ID:

let promise_id = format!("forecast-{latitude}-{longitude}");

let result: String = self
.resonate
.rpc(&promise_id, "get_forecast", (latitude, longitude))
.target("poll://any@workers")
.await
.map_err(|e| {
McpError::internal_error(format!("resonate rpc failed: {e}"), None)
})?;
// from example-mcp-tools-rs/src/bin/weather_server.rs:65

The durable primitives in play

#[resonate::function] — macro that registers a function as a durable workflow entry point. Applied to both the top-level workflow and each leaf side effect. src/bin/worker.rs:10, src/bin/worker.rs:64.

ctx.run(fetch_nws, ...) — invokes a registered function as a durable step. The return value is serialized to the Resonate server, so a crash after this point resumes from the stored result rather than re-running the HTTP request. src/bin/worker.rs:14, src/bin/worker.rs:29.

ctx.sleep(Duration) — durable sleep. Wakes against the original wall clock even if the worker is killed and another worker resumes the workflow. src/bin/worker.rs:26.

resonate.rpc(promise_id, fn_name, args).target(...) — Remote Function Invocation from the MCP server into the worker group. The promise ID is the deduplication key; identical IDs reconnect to the same in-flight execution. src/bin/weather_server.rs:67.

resonate.register(...) — registers a function with the worker so it can be invoked by name from another process. src/bin/worker.rs:102, src/bin/worker.rs:103.

group: Some("workers".into()) / target("poll://any@workers") — the worker process polls the workers group; the MCP server targets it by name. src/bin/worker.rs:98, src/bin/weather_server.rs:70.

What the SDK handles vs. what you write

You write: a plain async Rust function that fetches an URL, sleeps half a second, fetches a second URL, formats the result. You annotate it with #[resonate::function], wrap each side effect in ctx.run, replace tokio::time::sleep with ctx.sleep, and register it on a worker. On the MCP side you write a #[tool] method that builds a promise ID from the inputs and calls resonate.rpc(...).target(...).

The SDK handles: serializing each ctx.run return value to the Resonate server, replaying the workflow from the last checkpoint after a crash, routing the RPC to whichever worker in the workers group is available, and matching repeat calls with the same promise ID against the existing promise so they observe the same result.

Failure modes covered

Worker crashes between Step 1 and Step 3. The points URL fetched in ctx.run(fetch_nws, points_url) is checkpointed (src/bin/worker.rs:14). On recovery the workflow resumes at ctx.sleep or ctx.run(fetch_nws, forecast_url) rather than re-issuing the points request to NWS.

Worker is killed during ctx.sleep. The sleep wakes against the original wall clock; recovery on another worker continues to Step 3 once the deadline passes (src/bin/worker.rs:26).

MCP server process is restarted mid-call. The MCP server is stateless (src/bin/weather_server.rs:7). The client's retry will compute the same forecast-{lat}-{lon} promise ID and reconnect to the in-flight workflow (src/bin/weather_server.rs:65).

Concurrent tool calls for the same lat/lon. Both calls share a promise ID; the second observes the first's result rather than triggering a duplicate NWS request (src/bin/weather_server.rs:65).

curl exits non-zero. fetch_nws returns Error::Application with the curl exit code in the message (src/bin/worker.rs:81-88). The error is surfaced to the workflow against that step's promise.

NWS returns a body that fails to parse as JSON. Line 90 (let parsed: serde_json::Value = serde_json::from_slice(&body.stdout)?;) propagates the serde_json::Error through the ? operator; the SDK's #[from] serde_json::Error impl on Error (resonate-sdk-rs/resonate/src/error.rs:21-22) converts it to Error::SerializationError. The error is surfaced to the workflow against that step's promise rather than the JSON being silently dropped.

Forecast payload missing expected fields. properties.forecast or properties.periods absent produces Error::Application directly inside the workflow body (src/bin/worker.rs:20, src/bin/worker.rs:36).

When to reach for this pattern

If you're exposing a tool to an LLM client over MCP and the tool body makes more than one external call that can fail independently.

If the MCP server and the tool implementation should scale or be deployed separately — the MCP server is a thin stdio gateway, the workers run the durable logic.

If repeat tool calls with identical inputs should observe the same result (cache + deduplicate) without writing your own keyed store.

If a tool body needs to survive client-side restarts of the MCP server process without re-running side effects already completed.

If you want to run multiple workers behind one MCP server for crash recovery and load balancing — target("poll://any@workers") lets the Resonate server route to whichever worker is available (src/bin/weather_server.rs:70).

Sources

Example repo: https://github.com/resonatehq-examples/example-mcp-tools-rs

Resonate Rust SDK: https://github.com/resonatehq/resonate-sdk-rs (pinned to main branch, commit 63c348b per Cargo.lock)

Worker workflow source: src/bin/worker.rs

MCP server source: src/bin/weather_server.rs

Resonate docs — MCP tools pattern: https://docs.resonatehq.io/get-started/examples/mcp-tools

Resonate docs — Rust SDK guide: https://docs.resonatehq.io/develop/rust

Model Context Protocol: https://modelcontextprotocol.io