An MCP client (e.g. Claude Desktop) needs to call long-running, fault-tolerant tools — weather fetches that may time out, invoice approvals that wait on a human — without inventing retry, replay, or timer state inside every tool handler. Resonate turns the tool body into a durable execution: a registered generator workflow whose steps are checkpointed, replayed on re-entry, and retried on throw. The example-mcp-tools-ts repo ships two such tools, each a single-process stdio MCP server that registers one Resonate workflow and calls it from the MCP request handler.
The shape of the solution
The weather server registers one generator workflow and runs it from the MCP CallTool handler:
function* getForecast(
ctx: Context,
latitude: number,
longitude: number
): Generator {
try {
// Step 1: Get the forecast endpoint
const pointsUrl = `${NWS_API_BASE}/points/${latitude},${longitude}`;
const pointsData: any = yield* ctx.run(() => fetchNWS(pointsUrl));
if (!pointsData) {
return 'Unable to fetch forecast data for this location.';
}
// Step 2: Durable sleep (survives in-process replay)
yield* ctx.sleep(1000);
// Step 3: Get the actual forecast
const forecastUrl = pointsData.properties.forecast;
const forecastData: any = yield* ctx.run(() => fetchNWS(forecastUrl));
// ... (formatting elided)
return forecasts.join('\n\n---\n\n');
} catch (error) {
console.error('Weather forecast error:', error);
throw error;
}
}
// Register the main forecast function
const durableGetForecast = resonate.register('getForecast', getForecast);
// from example-mcp-tools-ts/src/weather-server.ts:60-102The MCP handler turns each tool call into a durable execution by passing an idempotency key plus the workflow arguments:
// Create a unique execution ID for this forecast request
const executionId = `${latitude}-${longitude}-${Date.now()}`;
// ...
const result = await durableGetForecast.run(executionId, latitude, longitude);
// from example-mcp-tools-ts/src/weather-server.ts:154,159The durable primitives in play
new Resonate()— instantiates a Resonate runtime. With nourloption (and noRESONATE_URLenv var), the SDK falls back to an in-memoryLocalNetwork: promises, tasks, and schedules live in in-processMaps. Both servers use this shape, so the durable log is in-process only. (src/weather-server.ts:26,src/invoice-server.ts:28)resonate.register(name, fn)— registers a generator function under a name and returns aResonateFunchandle. The handle is what you call from the MCP layer. (src/weather-server.ts:102,src/invoice-server.ts:137)durableX.run(id, ...args)— starts (or rejoins) a durable execution keyed byid. Same id, same recorded result. Dedup is enforced by the durable log; withLocalNetworkthat log is in-process. (src/weather-server.ts:159,src/invoice-server.ts:256)ctx.run(thunk)— records a step in the durable log. Throws drive retries; on replay within the same execution the recorded value is returned without re-executing the thunk. (src/weather-server.ts:68,src/weather-server.ts:79,src/invoice-server.ts:123)ctx.sleep(ms)— a durable timer. Survives in-process replay and the suspend/resume cycle the runtime uses to await it. Survives a process crash only when Resonate is connected to a remote Resonate server; withLocalNetworkthe timer dies with the process. (src/weather-server.ts:75,src/invoice-server.ts:102)function*+yield*— the v0.10.x SDK shape. Every durable step is reached viayield*so the runtime can suspend the generator at a checkpoint and resume it once the step settles. (both files)
What the SDK handles vs. what you write
| You write | The SDK handles |
|---|---|
A plain function* that takes ctx and your args, with yield* ctx.run(...) around side-effecting calls and yield* ctx.sleep(...) for waits. | Recording each step in a durable log, replaying completed steps when the workflow re-enters, retrying thrown steps, and driving durable timers. |
An idempotency key chosen for the call (e.g. invoice-${invoice_id} or ${lat}-${lon}-${Date.now()}), passed as the first argument to durableX.run(id, ...). | Deduplicating executions by id — same id rejoins the existing execution and returns its recorded result. |
The MCP request handler that maps a tool call to durableX.run(...). | Marshalling state so the workflow body itself doesn't have to know it's being re-entered. |
What the SDK does not handle in this repo:
- Cross-process durability. Both servers use
new Resonate()with nourl, which the SDK constructor JSDoc describes as falling back to a local in-memory network (@resonatehq/sdkdist/resonate.d.ts,dist/network/local.d.ts). The durable log lives in process memory. The README mentions a remote configuration for production; neither server wires it up. - Out-of-band state. The invoice server stores approval status in an in-memory
Map(src/invoice-server.ts:32-36). That Map is not durable even within the SDK's log — it is plain application state outside anyctx.run.
Failure modes covered
- The NWS API call fails or times out.
fetchNWSsetsAbortSignal.timeout(5000)and throws on non-2xx (src/weather-server.ts:41-49). The throw propagates out ofctx.run; Resonate retries the step. Earlier successful steps (e.g. the firstfetchNWSforpointsUrl) replay from the log rather than re-executing. - Mid-execution suspend/resume within one process. Each
ctx.runandctx.sleepis a checkpoint. When the runtime resumes the generator (e.g. after the 1 s sleep, or after a thunk settles), completed steps replay from the log and the workflow picks up at the nextyield*. This is the durability that the in-memoryLocalNetworkprovides; it does not extend across a process crash. For crash-survives semantics you would point Resonate at a remote Resonate server via theurloption; this repo does not. - A duplicate MCP tool call. For the invoice tool, the id is
invoice-${invoice_id}(src/invoice-server.ts:256). Submitting the sameinvoice_idagain rejoins the existing execution rather than starting a second one. (The weather tool deliberately opts out of this by includingDate.now()in its id — each forecast call is a fresh execution.) - Per-line payment failure on an approved invoice. Each line is processed under its own
ctx.run(() => processPaymentAsync(line))(src/invoice-server.ts:122-125), so a throw on line 3 retries only line 3 — lines 1 and 2 are not re-charged on replay. - The approver takes hours. The workflow loops on
yield* ctx.sleep(pollInterval)until status flips or 5 minutes elapse (src/invoice-server.ts:90-103). Sleeps are durable across in-process suspend/resume, so the runtime can keep the workflow parked between polls without burning a thread.
One failure mode this code does not cover: the in-memory approvals Map is process-local, and so is the durable log under LocalNetwork. If the process dies and is restarted, both are gone. A stronger pattern would (a) run Resonate against a remote Resonate server so the workflow log itself survives the crash, and (b) resolve a durable promise from the approval/reject MCP tools and have the workflow wait on that promise instead of polling a Map. This repo does not implement either.
When to reach for this pattern
- If you are exposing a workflow to an MCP client (Claude Desktop, another agent) and the workflow needs retries, replay, or durable timers — wrap it as a registered Resonate function and call
durableX.runfrom theCallToolRequestSchemahandler. - If a tool call hits flaky external APIs and you do not want each handler to reinvent retry/backoff, put the API call inside
ctx.runand let throws drive retries. - If a tool needs to wait — for a downstream system, a rate-limit window, or a human —
ctx.sleepis the durable form ofsetTimeoutthat survives the runtime's suspend/resume cycle. - If two MCP clients can race the same logical request, pick an idempotency key derived from the request (not
Date.now()) and pass it as the id todurableX.runso duplicates rejoin instead of fork. - If you need the workflow log itself to outlive a process crash, configure Resonate against a remote Resonate server via the
urloption; thenew Resonate()shape both servers use here is in-process only.
Sources
- Example repo:
https://github.com/resonatehq-examples/example-mcp-tools-ts - Pinned SDK:
@resonatehq/sdk ^0.10.0(package.json:23) - TypeScript SDK source:
https://github.com/resonatehq/resonate-sdk-ts - SDK constructor JSDoc on
urlfallback:@resonatehq/sdkdist/resonate.d.ts(If no URL is resolved, a local in-memory network is used) - SDK in-memory network state:
@resonatehq/sdkdist/network/local.d.ts(promises: Map<string, Promise>,tasks: Map<string, Task>,schedules: Map<string, Schedule>) - MCP server framework:
https://github.com/modelcontextprotocol/typescript-sdk(@modelcontextprotocol/sdk ^1.0.4,package.json:22) - Workflow code (lifted with explicit elision markers above):
src/weather-server.ts:60-102,src/weather-server.ts:154,159,src/invoice-server.ts:73-134,src/invoice-server.ts:256 - Resonate docs:
https://docs.resonatehq.io - MCP protocol:
https://modelcontextprotocol.io
