Transactional Workflows
Prerequisites
Install MCP Fusion before following this recipe: npm install @vinkius-core/mcp-fusion @modelcontextprotocol/sdk zod — or scaffold a project with npx fusion create.
- Introduction
- The Problem
- Solution: Compose Into a Single Tool
- Pattern 1: Sequential with Compensation
- Pattern 2: Check-Then-Act
- Pattern 3: Idempotent Retry-Safe Operations
- Combining with Runtime Guards
- Best Practices
Introduction
AI agents are stochastic. When a business workflow requires multiple steps — create a user, charge a card, send an email — the agent treats each step as an independent tool call. If any step fails, the previous steps have already executed. The result: corrupted data, phantom charges, and orphaned records.
MCP Fusion solves this without any new abstraction. The key insight: don't expose multi-step workflows as separate tools. Compose them into a single f.mutation() with manual compensation logic in the handler.
The Problem
A raw MCP server exposes three independent tools:
Agent → users.create ✅ → DB has a new user
Agent → billing.charge ✅ → Stripe charged the card
Agent → email.send ❌ → Zod fails 3× → timeoutThe agent made 3 tool calls. The first two succeeded, the third failed. The customer was charged but never received access. The server has no way to compensate — those tool calls are independent, fire-and-forget operations.
This is not an edge case. It is the default behavior of every AI agent interacting with independent MCP tools.
Solution: Compose Into a Single Tool
Instead of 3 tools, expose 1 tool that handles the entire workflow. The agent makes one call. The server orchestrates all steps internally. If any step fails, the handler compensates all previous steps before returning a self-healing error.
import { initFusion } from '@vinkius-core/mcp-fusion';
const f = initFusion<AppContext>();
export default f.mutation('onboarding.provision')
.describe('Provision a new user with billing and welcome email')
.withString('email', 'User email address')
.withNumber('plan_cents', 'Plan price in cents')
.destructive()
.handle(async (input, ctx) => {
// Step 1: Create user
const user = await ctx.db.user.create({
data: { email: input.email, status: 'pending' },
});
// Step 2: Charge card
let charge;
try {
charge = await ctx.payments.charge({
customerId: user.stripeId,
amount: input.plan_cents,
});
} catch (err) {
// Compensate step 1
await ctx.db.user.delete({ where: { id: user.id } });
return f.error('PAYMENT_FAILED', 'Card charge failed')
.suggest('Verify the payment method and retry onboarding.provision')
.actions('onboarding.provision')
.details({ reason: String(err) });
}
// Step 3: Send welcome email
try {
await ctx.mailer.send({
to: input.email,
template: 'welcome',
data: { userId: user.id },
});
} catch (err) {
// Compensate steps 1 + 2
await ctx.payments.refund({ chargeId: charge.id });
await ctx.db.user.delete({ where: { id: user.id } });
return f.error('EMAIL_FAILED', 'Welcome email could not be sent')
.suggest('Email service may be temporarily unavailable. Retry in 30 seconds.')
.actions('onboarding.provision')
.retryAfter(30);
}
// All succeeded — activate the account
await ctx.db.user.update({
where: { id: user.id },
data: { status: 'active' },
});
return { userId: user.id, chargeId: charge.id, status: 'active' };
});The agent calls one tool. If any step fails, the handler compensates everything and returns a self-healing error. No corrupted state. No orphaned records.
IMPORTANT
The agent doesn't know that 3 operations happen internally. It sees one tool, one input, one response. Atomicity is a property of the server, not the client.
Pattern 1: Sequential with Compensation
The most common pattern. Each step depends on the previous, and each has a compensation action for rollback:
export default f.mutation('subscription.activate')
.describe('Activate a subscription with payment and notifications')
.withString('user_id', 'User ID')
.withString('plan_id', 'Plan ID')
.destructive()
.handle(async (input, ctx) => {
// ── Step 1: Create subscription record ──
const sub = await ctx.db.subscription.create({
data: {
userId: input.user_id,
planId: input.plan_id,
status: 'provisioning',
},
});
// ── Step 2: Set up recurring billing ──
let billing;
try {
billing = await ctx.payments.createSubscription({
customerId: input.user_id,
priceId: input.plan_id,
});
} catch (err) {
await ctx.db.subscription.delete({ where: { id: sub.id } });
return f.error('BILLING_SETUP_FAILED', 'Could not set up recurring billing')
.suggest('Payment provider may be unavailable. Retry shortly.')
.actions('subscription.activate')
.retryAfter(10);
}
// ── Step 3: Grant feature access ──
try {
await ctx.features.grant(input.user_id, input.plan_id);
} catch (err) {
await ctx.payments.cancelSubscription({ id: billing.id });
await ctx.db.subscription.delete({ where: { id: sub.id } });
return f.error('FEATURE_GRANT_FAILED', 'Could not grant plan features')
.suggest('Internal error. Retry or escalate to support.')
.actions('subscription.activate')
.critical();
}
// ── All succeeded ──
await ctx.db.subscription.update({
where: { id: sub.id },
data: { status: 'active', billingId: billing.id },
});
return { subscriptionId: sub.id, status: 'active' };
});Each catch block undoes all previous steps in reverse order, then returns a f.error() with recovery instructions specific to the failure point.
Pattern 2: Check-Then-Act
Validate all preconditions before making any changes. This eliminates the need for compensation in most validation failures:
export default f.mutation('billing.process_refund')
.describe('Process a full refund for an invoice')
.withString('invoice_id', 'Invoice ID to refund')
.destructive()
.handle(async (input, ctx) => {
// ── Preflight checks (no side effects) ──
const invoice = await ctx.db.invoices.findUnique({
where: { id: input.invoice_id },
});
if (!invoice) {
return f.error('NOT_FOUND', `Invoice "${input.invoice_id}" not found`)
.suggest('Use billing.list_invoices to find valid IDs.')
.actions('billing.list_invoices');
}
if (invoice.status !== 'paid') {
return f.error('CONFLICT', `Invoice is "${invoice.status}" — only paid invoices can be refunded`)
.suggest(`Current status: ${invoice.status}. No action needed.`);
}
if (invoice.refundedAt) {
return f.error('CONFLICT', 'Invoice was already refunded')
.suggest('No action needed.')
.details({ refundedAt: invoice.refundedAt.toISOString() });
}
// ── All checks passed — execute atomically ──
const refund = await ctx.payments.refund({ chargeId: invoice.chargeId });
await ctx.db.invoices.update({
where: { id: invoice.id },
data: { status: 'refunded', refundedAt: new Date(), refundId: refund.id },
});
// Revoke access granted by this purchase
await ctx.features.revoke(invoice.userId, invoice.planId);
return { refundId: refund.id, status: 'refunded' };
});TIP
Place all validation before the first mutation. If every business rule check happens before the first await ctx.db.update(...), you never need compensation for validation errors — which are the most common failure mode with AI agents.
Pattern 3: Idempotent Retry-Safe Operations
Design workflows to be safely retried without double-execution. Use idempotency keys or status checks:
export default f.mutation('orders.fulfill')
.describe('Fulfill a pending order')
.withString('order_id', 'Order ID')
.idempotent()
.handle(async (input, ctx) => {
const order = await ctx.db.orders.findUnique({
where: { id: input.order_id },
});
if (!order) {
return f.error('NOT_FOUND', `Order "${input.order_id}" not found`)
.suggest('Use orders.list to find valid order IDs.')
.actions('orders.list');
}
// Idempotency: already fulfilled — return success without re-executing
if (order.status === 'fulfilled') {
return {
orderId: order.id,
status: 'fulfilled',
note: 'Already fulfilled — no action taken.',
};
}
if (order.status !== 'pending') {
return f.error('CONFLICT', `Order is "${order.status}" — only pending orders can be fulfilled`)
.suggest('Check order status with orders.get before retrying.');
}
// Execute fulfillment
await ctx.shipping.createShipment({ orderId: order.id });
await ctx.db.orders.update({
where: { id: order.id },
data: { status: 'fulfilled', fulfilledAt: new Date() },
});
return { orderId: order.id, status: 'fulfilled' };
});The .idempotent() annotation tells the LLM this operation is safe to retry. The handler enforces it by checking the current status before executing.
Combining with Runtime Guards
Layer the concurrency guard on top for defense in depth:
export default f.mutation('onboarding.provision')
.describe('Provision a new user account')
.concurrency({ maxActive: 3, maxQueue: 10 })
.withString('email', 'User email')
.withNumber('plan_cents', 'Plan price in cents')
.destructive()
.invalidates('users.*', 'billing.*')
.handle(async (input, ctx) => {
// ... sequential compensation pattern
});| Guard | What it prevents |
|---|---|
.destructive() | MutationSerializer serializes concurrent calls to this tool |
.concurrency() | At most 3 onboarding flows run simultaneously |
.invalidates() | After success, agent knows users and billing data is stale |
f.error().retryAfter() | On failure, agent waits before retrying |
Best Practices
One tool per business workflow — if it's a workflow, it's a single tool. Don't expose internal steps.
Compensate in reverse order — undo the most recent step first, working backwards.
Check-then-act — validate all preconditions before the first mutation to minimize compensation paths.
Use
f.error()with specific diagnostics — different failure points get different error codes, suggestions, and retry policies.Mark as
.destructive()— theMutationSerializerautomatically prevents concurrent execution of the same workflow.Add
.invalidates()— after a successful workflow, stale-data signals tell the agent to re-fetch affected domains.Design for idempotency — if the agent retries, the handler should detect the previous execution and return success without re-executing.
Use
.concurrency()for rate-sensitive workflows — billing and payment workflows should limit concurrent executions.
WARNING
Never expose the individual steps of a business workflow as separate tools. The AI will call them independently, and you cannot guarantee ordering or atomicity across separate tool calls. This is the single most common source of data corruption in agentic systems.