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-57The 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:65The 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 theworkersgroup; 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 atctx.sleeporctx.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 sameforecast-{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). curlexits non-zero.fetch_nwsreturnsError::Applicationwith 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 theserde_json::Errorthrough the?operator; the SDK's#[from] serde_json::Errorimpl onError(resonate-sdk-rs/resonate/src/error.rs:21-22) converts it toError::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.forecastorproperties.periodsabsent producesError::Applicationdirectly 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
mainbranch, commit63c348bperCargo.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
