Skip to main content
Subscribe
Front Page / AI Tools / Deep Dive

Bill Every MCP Tool Call: Idempotent Metering at 12ms

Meter every MCP tool call with idempotency keys, commit boundary and partial-stream policy that ends double billing at 12ms overhead in live deployment.

Deepak Bagada

Deepak Bagada

Founder & Editor-in-Chief

Sep 19, 2026 Published
|
Sep 19, 2026 Updated
|
7 Minutes Reading Time
Core Takeaways for Founders & Builders
  • Atomic claim plus commit boundary meters every tools-call exactly once across HTTP, SSE and stdio.
  • Partial and failed calls ledger by default and bill only under per-type policy, ending stream-abort disputes.
  • Pilot: 20,000 calls with 9 percent retries produced zero duplicates at 12ms P95 overhead.

Idempotent Metering for MCP Tool Calls

Every MCP tools-call across HTTP, SSE, and stdio gets metered exactly once with an idempotency key, an atomic claim, a commit boundary, and explicit partial-stream rules. Retries never double-charge. Dropped streams bill by policy, not accident. In our pilot this ended duplicate billing across 20,000 calls with 12ms P95 metering overhead and zero Stripe disputes.

  • Wrap any tool handler in meter dot track with an idempotency key from the client plus tenant and tool type.
  • commit marks the billing boundary when the server finishes producing, before handing bytes to the client.
  • Atomic claim runs at most once per key, pending TTL reclaims dead processes, and the sink never pretends an unbilled event was billed.

I added this after a retry storm double-billed our largest customer. Here is the exact primitive.

Billing Is Agreement on What Happened

Payment integration is the easy half. The hard half is deciding what happened after the fact. A retry repeats the same work. A dropped connection means half a response got produced. A stream gets cut right after the expensive part already ran. A slow call looks dead and gets reclaimed while still running.

Most MCP deployments meter only HTTP at the gateway. Claude Desktop and Claude Code default to stdio, and SSE streams pass through invisible. Unmetered transports mean unbilled cost. I meter at the tool handler with a one-line decorator plus a Kong plugin for HTTP, so all three transports collapse into one ledger. This is the same authority split I use for hardened Postgres RLS servers: policy at the edge, truth in the store.

War Story 1: The Retry That Billed Twice

Our search tool served Cursor agents over Streamable HTTP. A client timeout fired after 8 seconds. The client retried with no idempotency key. The server ran the full Tavily search twice, billed two Stripe meter events, and returned the same answer twice. One enterprise key did this 340 times in a week. Finance found 340 duplicate line items totaling 1,120 dollars. The customer opened a dispute. We refunded and ate the Stripe fees.

The fix was three lines. I required an idempotency key per tools-call, wrapped the handler in meter dot track, and keyed Stripe meter events to the same key. Redeliveries now return duplicate without re-running the callback or re-emitting to the sink.

  • Before: 340 retries became 680 billed events, 1,120 dollars in duplicates, 1 dispute.
  • After: 20,000 calls with 9 percent retries produced exactly 20,000 billed events, 0 duplicates, 12ms P95 overhead.

Do not trust clients to dedupe. The server owns the key. Here is why the key must arrive before any work starts.

The Primitive: Claim, Track, Commit

One decision, extracted: an idempotent commit boundary around work you already ship. No auth, no plans, no Stripe client inside the primitive. Those live outside as sinks and guards.

Track takes an id, tenant, type, and units plus a callback receiving commit. Claim is atomic: one check-and-set round trip, never select then insert. Concurrent calls with the same id are expected and tested. The second call gets outcome duplicate immediately without waiting for the in-flight original and without re-running the callback. This is deliberately not a result cache. If callers need the original payload on retry, serve your own idempotent read path keyed by the same id.

Commit defines completed as the server finished producing, not the client finished receiving. Call it after the search results are assembled, before returning the stream. If the client aborts after the server did the work, that is still billed. If the disconnect happens before the server finishes, catch it inside the work function, call commit with units actually produced, and rethrow. That turns the event into partial instead of failed. Billable defaults to completed only. Partial and failed are ledgered for audit and billed only when a per-type policy says so. Streaming bills once at end, not once per token. If report generation is expensive and clients can cut the stream to dodge the bill, give report its own billable entry that includes partial.

Crash recovery uses a pending TTL, default 24 hours to match Stripe idempotency windows. Claim on a settled record always returns duplicate. Claim on a young pending record returns duplicate. Claim on a stale pending record past the TTL reclaims and runs. Set the TTL above the P99.9 latency of the metered operation. Audit stale pendings with listStale rather than relying on TTL as the only signal. If the sink throws, the event stays recorded with billed false forever. Nothing pretends it was billed. On success paths the error surfaces as billingError for retry or alert. If claim or finalize on the success path fails, throw loudly through MeteringStoreError and fail closed before work runs. After work starts, refusing only discards effort without saving anything.

Benchmarks: Naive Billing vs Idempotent Metering

Setup: FastMCP Python 2.10 search tool over HTTP plus SSE plus stdio, Postgres ledger, Stripe billing meters as sink, 20,000 calls with 9 percent client retries, 3 percent mid-stream aborts, chaos kills on 200 in-flight calls.

Metric Naive per-call Stripe event Idempotent metering primitive Delta
Billed events for 20k calls 21,860 with dupes 20,000 exactly minus 100 percent dupes
Double charges on retry 1,860 0 zero
Partial-stream disputes 14 open 0, policy billed partials closed
P95 added latency 0 plus 12ms acceptable
Crash-loss events 212 unrecorded 0, pending TTL reclaimed full ledger
Sink-failure honesty 38 pretended billed 0, billed false retained truthful

Twelve milliseconds buys a ledger auditors believe. For transport-level theft and token hardening on the same fleet, pair this with our hardened FastMCP OAuth proxy.

Step 1: Ledger Store and Schema

One table, atomic claims, ordered history. Postgres gets a metering_events table with id text primary key, tenant text, tool type text, units numeric, status pending completed partial failed duplicate, billed boolean, created and updated timestamps, and a Stripe event reference nullable. Claim is a single INSERT ON CONFLICT DO UPDATE WHERE that only reclaims stale pendings past the TTL. Everything else returns duplicate. This atomicity is the whole guarantee. A select-then-insert races under concurrent retries and double-bills exactly when load peaks.

File requirements.txt pins fastmcp 2.10.0, psycopg 3.2.0 pool, pydantic 2.9.1, stripe 11.0.0 for the sink only. File config.py holds DATABASE_URL, PENDING_TTL_MS at 24 hours, BILLABLE defaults to completed, per-type overrides for expensive report tools that include partial, Stripe meter IDs per tool, and per-tier rate limits outside the primitive.

File store.py exposes claim, checkpoint for partial progress, finalize with status plus units, markBilled after sink ack, and listStale for alerting. The Postgres implementation takes a minimal query interface so pgBouncer-fronted pools and PgLite work unchanged. Memory store covers single-process tests with a synchronous map check-and-set.

Verify atomicity before shipping. Fire 50 concurrent tracks with the same id against Postgres. Exactly one callback execution, one billable emit, 49 duplicates. If two callbacks run, the claim is not atomic and nothing else matters.

Step 2: Wrap FastMCP Tools in Three Lines

The retrofit wraps the body of an existing handler. Keep returning what it already returns. Errors propagate unmodified so surrounding try logic behaves identically. The meter only adds the boundary.

File metering.py exposes createMeter with store, billable, billableByType, onBillable sink, onError hook, and pending TTL. Track is async, takes the key envelope plus the work callback, and returns outcome plus value plus optional billingError.

# metering.py (wrap sketch)
meter = createMeter(store=postgres_store(pool), billable=["completed"],
                    billableByType={"report": ["completed", "partial"]},
                    on_billable=report_to_stripe)

@mcp.tool(tags={"paid"})
async def web_search(q: str, ctx) -> dict:
  key = idempotency_key(ctx)  # header, else session plus nonce
  return await meter.track({"id": key, "tenant": tenant(ctx),
                            "type": "web_search", "units": 1},
    lambda commit: do_search(q, commit))

Inside do_search, assemble results, call commit once producing is complete, then return. On client abort before completion, catch the disconnect, commit partial units actually produced, and rethrow. The ledger records partial. The sink bills it only for types whose policy includes partial. Full billing-guard stacks with OAuth plus tiers plus Stripe live outside this file. For approval-gated risky tools behind the same gateway, see our elicitation approval MCP gate.

Step 3: Cover All Three Transports and Sync Stripe

HTTP uses a Kong Lua plugin at post-response log phase for zero inference latency. SSE and stdio use the same one-line decorator around each handler. Every tools-call emits a structured event with metric name, product type, tool name, session ID, agent ID, duration milliseconds, and token metadata. ClickHouse or Postgres materializes per-tool revenue, per-agent cost, and session margin. Heartbeat ACKs carry kill payloads when Redis compare-and-increment breaches hard limits, terminating runaway stdio transports immediately.

Stripe sync maps one billable event to one meter event with the same idempotency key. Retries to Stripe reuse the key. Webhook handlers distinguish invalid signatures, which must not retry, from transient DB errors, which must. Early code returned the same 400 for both and silently dropped billing events. Separate the codes and test both paths. For downstream agent fleets that consume metered tools at scale, see Kafka Temporal LangGraph fraud agents.

War Story 2: The Slow Call the TTL Ate

Our report tool has P99 latency of 26 hours for giant exports. Default 24-hour TTL reclaimed a still-running call on retry and executed the export twice. Cost: 410 dollars in compute plus a duplicate 90-dollar line item. The fix set pending TTL per type above P99.9 latency: 30 hours for report, 60 seconds for search. Stale audits now alert at half TTL. No reclaims of live work in 60 days since.

When NOT to Use This Pattern

Let us be direct. Do not meter what does not need it.

  • Free internal tools with no chargeback: ledger-only mode without a sink, or nothing at all. Metering adds 12ms and a table for zero revenue signal.
  • Sub-5ms hot paths at extreme QPS: the claim round trip shows. Batch meter at the session level instead of per call.
  • Result replay on duplicate: this primitive returns duplicate without the original payload by design. If clients need payloads on retry, build your own idempotent read path first.
  • Single HTTP gateway already metered: if stdio and SSE truly do not exist in your fleet, gateway metering suffices. Most fleets grow stdio quickly through Claude Code. Recheck quarterly.

Bottlenecks and Trade-offs

Ledger write amplification caps throughput. One claim plus one finalize per call doubles writes. Batch finalize on streaming chunks, keep the ledger table narrow, index tenant plus created time, and archive settled rows past 90 days. Sink latency must never block tool results. Emit to Stripe asynchronously with bounded retries and a dead-letter queue. A slow sink that blocks responses turns billing into an outage.

Pending TTL tuning is risk work. Too short reclaims live calls and double-executes. Too long blocks legitimate retries after crashes for hours. Set per type from measured P99.9, alert on stale pendings, and load-test with kills during every release.

Ship Checklist

  1. Atomic claim proven with 50-way concurrent same-key test, ledger table with billed-false honesty.
  2. Commit boundaries on every paid tool, partial handling inside work functions, per-type billable policy.
  3. All three transports emitting with the same key envelope, Stripe sink keyed identically.
  4. Kill tests for workers, relays, and sinks with zero duplicate bills and zero pretended billing.

Start with one paid tool, prove zero dupes, then expand. Our first wrap took an hour and ended a dispute the same week.

By , Founder and Editor-in-Chief at Daily AI World. I build agentic systems at SaaSNext and write from production logs, not demos. Follow @deeepakbagada and read more at https://deepakbagada.in.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

🎉 Thank You for Subscribing!

Frequently Asked Questions
Each tools-call carries an idempotency key. Atomic claim runs the callback at most once per key. Redeliveries return duplicate without re-running work or re-emitting to Stripe.
Completed by default, meaning the server finished producing. Partial bills only for tool types whose policy includes it. One event per completed stream, never per token.
All three. HTTP uses a gateway plugin while SSE and stdio use the same handler decorator, collapsing into one ledger with the same key envelope.
The event stays recorded with billed false. Nothing pretends it was billed. Success paths surface billingError for retry while failure paths expose it through the error hook.
Deepak Bagada
Author Profile

Deepak Bagada

Founder & Editor-in-Chief

Deepak Bagada is the founder and Editor-in-Chief of Daily AI World and CEO of SaaSNext. He covers enterprise AI architecture, high-concurrency agent workflows, Model Context Protocol tooling, and frontier AI systems engineering.

Related Intelligence Analysis

Briefing AI Tools

Vercel AI SDK Tool Calling React: 5 Steps (2026)

Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

Fact-Density vs. Word Count: The New SEO for 2026

Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...

Deepak Bagada Deepak Bagada
4m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc

Cookie & Privacy Preferences

We use cookies and telemetry tools to deliver technical dispatches, benchmark analytics, and advertising via Google AdSense. Review our Privacy Policy.