Exposing Resonate workflows as MCP tools in TypeScript

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

The 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,159

The durable primitives in play

new Resonate() — instantiates a Resonate runtime. With no url option (and no RESONATE_URL env var), the SDK falls back to an in-memory LocalNetwork: promises, tasks, and schedules live in in-process Maps. 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 a ResonateFunc handle. 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 by id. Same id, same recorded result. Dedup is enforced by the durable log; with LocalNetwork that 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; with LocalNetwork the 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 via yield* 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 no url, which the SDK constructor JSDoc describes as falling back to a local in-memory network (@resonatehq/sdk dist/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 any ctx.run.

Failure modes covered

The NWS API call fails or times out. fetchNWS sets AbortSignal.timeout(5000) and throws on non-2xx (src/weather-server.ts:41-49). The throw propagates out of ctx.run; Resonate retries the step. Earlier successful steps (e.g. the first fetchNWS for pointsUrl) replay from the log rather than re-executing.

Mid-execution suspend/resume within one process. Each ctx.run and ctx.sleep is 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 next yield*. This is the durability that the in-memory LocalNetwork provides; it does not extend across a process crash. For crash-survives semantics you would point Resonate at a remote Resonate server via the url option; 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 same invoice_id again rejoins the existing execution rather than starting a second one. (The weather tool deliberately opts out of this by including Date.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.run from the CallToolRequestSchema handler.

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.run and let throws drive retries.

If a tool needs to wait — for a downstream system, a rate-limit window, or a human — ctx.sleep is the durable form of setTimeout that 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 to durableX.run so 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 url option; the new 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 url fallback: @resonatehq/sdk dist/resonate.d.ts (If no URL is resolved, a local in-memory network is used)

SDK in-memory network state: @resonatehq/sdk dist/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