Skip to content
Shenzhen · The Greater Bay Area · Earth

How to Build a Zero-Marginal-Cost B2B AI Workflow with Next.js and the Model Context Protocol

Most B2B AI workflows fail on cost structure, not model quality. This is the architecture I ship: narrow MCP tools, stateless orchestration, idempotent writes, and a cost model you can defend in a procurement meeting.

9 min read1,948 words
AI WorkflowsModel Context ProtocolNext.js ArchitectureSystemsThis piece is also available in 中文

What does "zero marginal cost" actually mean for a B2B workflow?

A zero-marginal-cost workflow is one where the ten-thousandth run costs the same shape as the first: a few cents of inference, a few hundred milliseconds of CPU, and no additional headcount.

That is the whole claim. It is not that inference is free. It is that the cost curve stops bending. The first run and the ten-thousandth run differ by a constant factor of traffic, not by a step function of hiring.

Traditional B2B software is priced per seat because value used to scale with humans. That assumption breaks the moment the work is done by an agent. A quoting workflow that runs 9,000 times a month for 40 people is not a 40-seat problem, and pricing it per seat creates a strange incentive: the customer pays the same whether the workflow runs once or ten thousand times, so nobody inside the vendor's organisation has a reason to make it cheap. Per-token economics invert that. Every optimisation you ship shows up in your own gross margin, and it shows up within a month.

I build these workflows for enterprise clients, and the pattern that survives contact with procurement is consistent: put the LLM where judgment is required, keep the deterministic core deterministic, and make every run's cost legible in a single table.

What does one run of an AI workflow actually cost?

The useful unit is not the token. It is the cost per successful run, including retries and failed attempts. Those three numbers belong in the same spreadsheet.

Take a mid-sized quoting workflow: 8,000 tokens of stable system prompt plus rate card, 2,000 tokens of volatile customer brief, 1,200 tokens of output, and roughly four tool calls. Using illustrative unit prices of $0.25 per million cached input tokens, $2.50 per million fresh input tokens, and $10.00 per million output tokens — substitute your provider's current numbers, the structure does not change — one run costs:

  • cached input: 0.008M × $0.25 = $0.002
  • fresh input: 0.002M × $2.50 = $0.005
  • output: 0.0012M × $10.00 = $0.012
  • total: about $0.019 per run, or 1.9 cents

At 10,000 runs a month that is $190 in inference. The same volume on a $29-per-seat plan across 50 seats is $1,450. The margin difference is not a rounding error; it is the difference between a workflow you can bundle and a workflow you have to meter carefully.

Two structural details matter more than model choice:

Stable prefix first, volatile content last. Prompt caching only pays if the cached prefix is byte-identical across calls. Put the system prompt, tool definitions, and rate card in a fixed order at the top, and append the customer brief at the end. If you interpolate a timestamp or a request ID into the first line, you have just disabled caching for the whole run and tripled your input cost.

Batch to the cache TTL. Most providers hold a cache for minutes, not hours. A workflow triggered by one user at a time wastes it. A queue that drains every 60 seconds serves hundreds of runs from one cache write.

DimensionPer-seat SaaSPer-token agent runHybrid (platform fee + metered runs)
Unit of pricingNamed human userToken, or a runSeat floor plus run overage
Marginal cost to vendor at run 10,000Near zero, but margin capped by seat countSmall, linear, visible in COGSSmall, linear, and blended
Who absorbs usage growthVendor, invisiblyCustomer, transparentlyShared, contractually defined
Procurement frictionLowHigh at first, until a ceiling is agreedMedium
Failure modeCustomer notices value without seat growthCustomer fears an unbounded billContract complexity
Best fitHuman-driven toolsWorkflows where agents do the workEnterprise accounts that need a floor

The hybrid row is usually where enterprise deals land. Sell a platform fee that covers the fixed cost of your integration work, then a metered run allowance with a hard ceiling the customer sets. Nobody signs an uncapped token contract in the first meeting.

Where does the orchestrator belong in the business process?

Split every workflow into three kinds of steps before you write a prompt.

Steps that are deterministic — pricing arithmetic, tax, inventory decrement, currency conversion, permission checks — must not be delegated to a language model under any circumstances. Arithmetic is not an LLM capability; it is a library call. If the model computes a total, you now have to test a stochastic function for correctness on the one output that a finance team will audit.

Steps that are judgment — classifying an inbound request, mapping a messy brief to a catalogue of SKUs, choosing the appropriate tone for a renewal letter, deciding whether a request needs human review — are where the LLM earns its place. These are the steps where a rule engine becomes a thousand-line switch statement nobody wants to maintain.

Steps that are retrieval — fetching the active rate card, the last three quotes for this account, the current margin floor — are tool calls. They are not memory.

The orchestrator may decide; it must never remember. Every fact the workflow needs is fetched through a tool or read from the database, never carried forward in conversation history.

This rule is what makes the failure modes tractable. Conversation history is an unbounded, unvalidated, unversioned state store. The moment business state lives there, you cannot reproduce a bad run, cannot replay it after a prompt change, and cannot answer "why did it quote 12,000 instead of 9,600" three weeks later.

Designing the MCP server so the model cannot improvise

The Model Context Protocol gives you a typed boundary between the model and your system. Treat the tool surface as a public API with a hostile client, because that is what it is: the client is a probabilistic process that will pass you a plausible-looking argument that is semantically wrong.

Here is a real quoting server, trimmed to the two tools that matter.

// mcp/quoting-server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { findActiveRateCard } from "@/lib/rate-cards";
import { createQuoteDraft } from "@/lib/quotes";

const server = new McpServer({ name: "quoting", version: "1.4.0" });

server.registerTool(
  "get_rate_card",
  {
    title: "Get active rate card",
    description:
      "Return the rate card in effect today for a customer tier. Call this before drafting a quote; rates change monthly and must never be assumed.",
    inputSchema: {
      tier: z.enum(["starter", "growth", "enterprise"]),
      currency: z.enum(["USD", "CNY"]).default("USD"),
    },
    annotations: { readOnlyHint: true, idempotentHint: true },
  },
  async ({ tier, currency }) => {
    const card = await findActiveRateCard(tier, currency);
    if (!card) {
      return {
        isError: true,
        content: [{ type: "text", text: `no_active_rate_card tier=${tier} currency=${currency}` }],
      };
    }
    return {
      content: [{ type: "text", text: `rate card ${card.rateCardId}, ${card.lines.length} lines` }],
      structuredContent: card,
    };
  },
);

server.registerTool(
  "create_quote_draft",
  {
    title: "Create quote draft",
    description:
      "Persist a draft quote and return its id. Requires the rateCardId you read, so a stale card is rejected. Pass the caller's idempotencyKey unchanged.",
    inputSchema: {
      accountId: z.string().uuid(),
      rateCardId: z.string(),
      lines: z
        .array(z.object({ sku: z.string(), quantity: z.number().int().positive() }))
        .min(1)
        .max(40),
      note: z.string().max(600),
      idempotencyKey: z.string().min(16),
    },
    outputSchema: {
      quoteId: z.string(),
      status: z.enum(["draft", "needs_review"]),
      subtotalCents: z.number().int(),
    },
    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
  },
  async (args) => {
    const result = await createQuoteDraft(args);
    return {
      content: [{ type: "text", text: `draft ${result.quoteId} (${result.status})` }],
      structuredContent: result,
    };
  },
);

await server.connect(new StdioServerTransport());

Three decisions in that file are load-bearing.

The rateCardId is a required input to the write tool. The model cannot invent prices because it cannot invent the row it is quoting against; if the card rotated between read and write, the server rejects the draft instead of silently repricing it.

The tool returns structuredContent alongside human-readable text. Your orchestration code reads the structured field; a language model may read either. The principle is the same one behind making a site legible to language models: give the machine structure and keep prose as the fallback. Mixing the two — parsing prose to recover a number — is how you get an invoice for 12,000 RMB with a currency of USD.

idempotentHint: true is a promise the model can rely on. When the client retries after a transport error, the same key returns the same draft rather than a second one.

The corresponding protocol messages are ordinary JSON. A trimmed tools/list response looks like this:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "get_rate_card",
        "description": "Return the rate card in effect today for a customer tier.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "tier": { "type": "string", "enum": ["starter", "growth", "enterprise"] },
            "currency": { "type": "string", "enum": ["USD", "CNY"], "default": "USD" }
          },
          "required": ["tier"],
          "additionalProperties": false
        },
        "annotations": { "readOnlyHint": true, "idempotentHint": true }
      },
      {
        "name": "create_quote_draft",
        "inputSchema": {
          "type": "object",
          "properties": {
            "accountId": { "type": "string", "format": "uuid" },
            "rateCardId": { "type": "string" },
            "lines": { "type": "array", "minItems": 1, "maxItems": 40 },
            "idempotencyKey": { "type": "string", "minLength": 16 }
          },
          "required": ["accountId", "rateCardId", "lines", "idempotencyKey"]
        }
      }
    ]
  }
}

additionalProperties: false and a bounded array length do more for reliability than any amount of prompt instruction. Constraints the schema enforces are constraints you do not have to test for afterwards.

Streaming the workflow out of a Next.js App Router route

Long workflows need progressive output, and Next.js route handlers stream naturally. The critical discipline is that the stream carries events, not state. Nothing in the client's rendering path becomes the source of truth.

// app/api/quotes/draft/route.ts
import type { NextRequest } from "next/server";
import { claimRun } from "@/lib/runs";
import { runQuoteWorkflow } from "@/lib/workflow";
import { classifyError, isRetryable } from "@/lib/errors";

export const runtime = "nodejs";
export const maxDuration = 60;

export async function POST(req: NextRequest) {
  const { brief, accountId, tenantId, idempotencyKey } = await req.json();

  const run = await claimRun({
    idempotencyKey,
    tenantId,
    workflow: "quote.draft.v3",
    inputHash: await hashInput({ brief, accountId }),
  });

  if (run.state === "succeeded") {
    return Response.json({ replayed: true, result: run.result });
  }

  const encoder = new TextEncoder();
  const body = new ReadableStream<Uint8Array>({
    async start(controller) {
      const emit = (event: unknown) =>
        controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
      try {
        for await (const step of runQuoteWorkflow({ brief, accountId, tenantId, runId: run.id })) {
          emit(step); // { type: "token" | "tool_call" | "tool_result" | "usage", ... }
        }
      } catch (error) {
        emit({ type: "error", code: classifyError(error), retryable: isRetryable(error) });
      } finally {
        controller.close();
      }
    },
  });

  return new Response(body, {
    headers: {
      "content-type": "application/x-ndjson",
      "cache-control": "no-store",
      "x-run-id": run.id,
    },
  });
}

claimRun runs before any model call. That ordering is deliberate: the expensive work must be gated behind a cheap uniqueness check, otherwise two browser tabs generate two invoices for the same order.

How do you make retries safe for money-touching writes?

Idempotency is a database concern, not an application concern. Enforce it with a unique constraint and let the database arbitrate.

// lib/runs.ts
import { db } from "@/lib/db";
import { runs } from "@/lib/schema";
import { eq } from "drizzle-orm";

export async function claimRun(input: {
  idempotencyKey: string;
  tenantId: string;
  workflow: string;
  inputHash: string;
}) {
  const [inserted] = await db
    .insert(runs)
    .values({ ...input, state: "running", startedAt: new Date() })
    .onConflictDoNothing({ target: runs.idempotencyKey })
    .returning();

  if (inserted) return inserted;

  const existing = await db.query.runs.findFirst({
    where: eq(runs.idempotencyKey, input.idempotencyKey),
  });

  if (!existing) {
    // Another transaction inserted and rolled back. Retry once, then surface it.
    throw new Error(`run_claim_race key=${input.idempotencyKey}`);
  }
  if (existing.inputHash !== input.inputHash) {
    throw new Error("idempotency_key_reused_with_different_input");
  }
  return existing;
}

The inputHash check is the part most teams skip. Without it, a client that reuses a key with a modified brief gets the old result back and believes it succeeded. Silent wrong answers are more expensive than loud errors.

Set a retention window on those keys — 24 hours is usually right for a user-triggered workflow, 90 days if a partner integration may replay a batch. And never sync a session cookie or locale setting into the key; the key should identify the intent, not the render.

Retries, backoff, and a failure taxonomy you can act on

Retry logic is useless without a classification step. Retrying a validation error is waste; failing to retry a 429 is user-visible flakiness.

// lib/retry.ts
export async function withRetry<T>(
  operation: () => Promise<T>,
  { attempts = 5, baseMs = 400, maxMs = 8_000 } = {},
): Promise<T> {
  let lastError: unknown;

  for (let attempt = 0; attempt < attempts; attempt += 1) {
    try {
      return await operation();
    } catch (error) {
      lastError = error;
      if (!isRetryable(error) || attempt === attempts - 1) throw error;

      const serverHint = retryAfterMs(error); // honours Retry-After when present
      const exponential = Math.min(maxMs, baseMs * 2 ** attempt);
      const jittered = (serverHint ?? exponential) * (0.5 + Math.random());

      await new Promise((resolve) => setTimeout(resolve, jittered));
    }
  }

  throw lastError;
}
Failure classSignalRetry?What to fix
Rate limitHTTP 429, or a provider-specific overload codeYes, honour Retry-AfterAdd a queue; smooth burst demand
Transient transportSocket reset, 502/503 from the gatewayYes, with jitterPin the region; check egress NAT limits
Schema violationTool args fail validationNoTighten the schema; add a worked example to the tool description
Business rule rejectionneeds_review, stale rate cardNoRoute to a human queue, do not loop the model
Context overflowPrompt exceeds the windowNoSummarise retrieved documents before insertion
Provider outageSustained 5xx for one modelYes, once, against a fallbackKeep a second provider behind the same tool contract

The last row deserves emphasis. A fallback model behind an identical tool contract is cheap insurance. Because the orchestrator holds no state and the tools are typed, switching models is a configuration change, not a rewrite. That property is the main reason I keep the boundary strict, even when a single-model prototype would be faster to write.

Observability: cost per successful run

Log one structured record per run, at the moment the run reaches a terminal state. It should contain the run id, tenant, workflow version, model id, input and output token counts, cache hit tokens, tool call count and durations, retry count, terminal state, and the computed cost in cents. Store it in the same table as the business result, not in a separate analytics pipeline that drifts.

# inspect the tool surface on its own before a model ever touches it
pnpm dlx @modelcontextprotocol/inspector node dist/mcp/quoting-server.js

# one real run, then read the cost line the workflow emitted
curl -sS -X POST http://localhost:3000/api/quotes/draft \
  -H 'content-type: application/json' -H 'x-idempotency-key: 01HQ8Z6M4T9K2V' \
  -d '{"brief":"50 seats of onboarding video, zh-CN, 3 revisions","accountId":"8f2a","tenantId":"acme"}' \
  | tail -n 1 | jq '{state, out_tokens: .usage.output_tokens, cents: .cost.usd_cents}'

Then watch three numbers. P95 end-to-end latency, because the mean hides the queue. Cost per successful run, because a workflow that retries twice on a third of its traffic costs 1.6× what the happy path suggests. And the ratio of needs_review to automatic completions, because a rising review rate means the input distribution has shifted and your prompt is now confidently wrong.

What zero marginal cost does not mean

It does not mean the workflow is free to operate. You still pay for the database, the queue, the egress, and the engineer who reads traces at 11pm. It means those costs grow with infrastructure rather than headcount, which is the structural promise of a system that runs without the person present, so the incremental decision — one more run, ten thousand more runs — has an answer you can compute in advance.

That is the real deliverable: a workflow whose cost you can forecast is one you can price, bundle, and defend in a contract review. It is also the shape of the work I do in the B2B engagements I take on.

Closing: build the boundary before the prompt

Start with the tool contract, not the system prompt. Write the two or three tools the workflow genuinely needs, constrain their inputs until an incorrect call is impossible rather than merely unlikely, put the idempotency key in the write path, and log cost per successful run from the first deployment. The prompt will change a dozen times in the first month; the tool boundary, if it is well designed, will not. Everything else — model choice, temperature, prompt style — is tuning on top of a structure that either holds or does not.

Keep reading

More in AI Systems

Ready to build a system?[ Book a Call ]