Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 27: Cost Management

The first MCP bill that surprises you will not be the model line. It will be the downstream API that search_tickets called 40 times in one conversation, because the model read each empty page as a reason to try a slightly different status filter. Every one of those calls succeeded, so no error alert fired. The only signal was cost.

Cost work starts with attribution. If you cannot connect spending to tools, tenants and workflows, every optimization conversation turns into opinion. The rule for this chapter: every tool call emits one cost event keyed by tenant, tool and trace, and every workflow-level number is labelled as an estimate built from those events.

Cost Is a Workflow Property

For one user task, the real cost includes prompt and completion tokens, the tool definitions the model re-reads on every turn, server compute, downstream API and database charges, and the human time spent when automation produces an ambiguous result. A cheap model call that triggers an expensive downstream workflow is still an expensive workflow.

The reporting that helps cuts cost four ways: per workflow, per tool, per tenant and per downstream dependency. Those cuts separate problems that look identical on a monthly total: the model choosing too many tools, the server returning more context than the next step needs, a downstream API being the real cost centre, or one tenant’s usage pattern dominating the platform.

The Server Cannot See the Workflow

That reporting has a hole in the middle. An MCP server receives tools/call requests. It gets no task ID, no conversation ID and no token counts: the model’s usage is billed to whoever runs the client, and the protocol does not report it back. The server sees calls, not workflows.

There are three ways to group calls into workflows, in order of preference:

  1. Trace context in _meta. The 2026-07-28 revision reserves traceparent, tracestate and baggage in request _meta for W3C trace context. When the client propagates a trace, every call in one agent turn shares a trace_id, and the grouping is exact. Client support is uneven, so measure what share of your traffic arrives traced before you build reports on it.
  2. A workflow ID from clients you control. Your own agents and internal clients can put a workflow ID in baggage or a request header. It is client-supplied, so use it for attribution only, never for authorization.
  3. Heuristic grouping. With neither, group by principal and time: calls from the same subject with gaps under, say, 90 seconds count as one workflow. This overcounts parallel tasks and splits long pauses. Label it as a heuristic on every chart that uses it.

Say this plainly in every cost review: tool-level numbers are measured, and workflow-level numbers are estimates stitched together from them.

One Cost Event Per Tool Call

The tickets server meters with SDK middleware, so no handler changes. The ticket-API client calls count_downstream() once per HTTP request it makes:

import hashlib
import json
import time
from contextvars import ContextVar

downstream_calls: ContextVar[int] = ContextVar("downstream_calls", default=0)
_recent: dict[str, tuple[float, str | None]] = {}  # one replica; use Redis with a TTL behind a balancer


def count_downstream() -> None:  # the ticket-API client calls this once per HTTP request
    downstream_calls.set(downstream_calls.get() + 1)


class Metering:
    def __init__(self, emit, tenant_of, retry_window_s: float = 60):
        self.emit, self.tenant_of, self.window = emit, tenant_of, retry_window_s

    async def __call__(self, ctx, call_next):
        if ctx.method != "tools/call":
            return await call_next(ctx)
        tool, args, tenant = ctx.params["name"], ctx.params.get("arguments") or {}, self.tenant_of()
        parts = str((ctx.meta or {}).get("traceparent", "")).split("-")
        trace_id = parts[1] if len(parts) == 4 else None
        key = hashlib.sha256(json.dumps([tenant, tool, args], sort_keys=True).encode()).hexdigest()
        prior, start = _recent.get(key), time.monotonic()
        downstream_calls.set(0)
        result = await call_next(ctx)
        size = len(json.dumps(result, default=str))
        _recent[key] = (time.time(), trace_id)
        self.emit({
            "tool": tool, "tenant": tenant, "trace_id": trace_id,
            "downstream_calls": downstream_calls.get(), "response_bytes": size,
            "est_tokens": size // 4,  # an estimate, not a tokenizer count
            "duration_ms": round((time.monotonic() - start) * 1000),
            "retry_of": prior[1] if prior and time.time() - prior[0] < self.window else None,
        })
        return result

Register it with MCPServer("tickets", middleware=[Metering(emit, tenant_of)]), where tenant_of returns tenantId from the request’s verified AuthContext. Two fields do most of the work. retry_of is set when the same tenant sent the same tool and arguments within the window, and it holds the earlier call’s trace_id: that turns “track retries separately” from advice into a query. est_tokens is response_bytes // 4, a rough bytes-per-token estimate that is consistent over time, which matters more for trend lines than accuracy does. These fields extend the operational event in Chapter 23; emit them on the same record if you already log one.

Budget Controls Belong at Every Layer

LayerControls
Client or workflowSoft warnings, approval for costly actions, retry limits
MCP serverPer-tool quotas, concurrency caps, response-size limits, ttlMs on lists so clients stop re-listing
GatewayTenant budgets, global rate limits, the per-tenant tool firewall from Chapter 26 as the kill switch
ReportingShowback per tenant and tool, reviewed monthly

Costs fail in different ways. One incident is a prompt loop. Another is one expensive tool called too freely. Another is a large tenant onboarded without anyone updating quota assumptions. A single layer catches only one of them.

The cheapest lever is usually the interface itself: tools that return only what the next step needs, and a catalog small enough to choose from quickly. Chapter 13 covers the token side in detail.

Shared platforms also need visible accountability. Even without formal chargeback, give each team a monthly showback of what its tenants consumed by tool, and review which tools have the worst cost-to-value profile and which grew fastest since the last interface change.

A Worked Forecast

cost per workflow = calls per workflow × cost per call looks complete until you put numbers in it. The numbers below are illustrative, chosen to show the shape of the calculation.

Assume 50 tenants and 5,000 workflows a day, so the average tenant runs 100. Each workflow makes 3.2 tickets calls on its happy path. Per call: about 1,500 input tokens of definitions and results at $3 per million ($0.0045), 1.5 downstream API requests at $0.002 each ($0.003), and $0.0002 of compute, for $0.0077.

Average tenantTop tenant
Workflows per day1001,000 (10×)
Retry multiplier (from retry_of)1.31.8
Calls per workflow4.165.76
Calls per day4165,760 (13.8×)
Cost per workflow$0.032$0.044
Cost per day$3.20$44.35

The planning forecast with the average retry rate says $160 a day for the platform. With the top tenant at its measured rate, it is $172: 8% over overall, but 38% over for that one tenant. The quota is worse. A per-tenant limit sized at 10× the average tenant’s 416 calls allows 4,160 calls a day, and the top tenant needs 5,760. It gets throttled mid-afternoon on its first full day, by a limit that looked generous on paper.

Forecasts usually fail because retry and loop behaviour were treated as edge cases. In model-driven systems they are ordinary traffic, and they compound with volume.

Failure Modes

  1. Loop storm. The model calls the same tool with near-identical arguments because each result looks like progress. Detect it with the retry_of rate per tool and more than five calls to one tool per trace_id.
  2. Payload bloat. A release adds fields to a result “because the model can decide what matters”, and every later turn re-reads them. Detect it with p95 response_bytes per tool, compared across releases.
  3. Hidden fan-out. One tool quietly makes a dozen downstream requests per call. Token dashboards show nothing wrong. Detect it with downstream_calls per call per tool.
  4. Average-tenant quotas. Limits sized from the mean throttle the largest tenant first, usually during an onboarding. Detect it by tracking the top tenant’s share of calls and its retry_of rate separately from the mean.
  5. Untraced traffic. Most calls arrive without traceparent, so workflow numbers rest on the time-window heuristic without anyone noticing. Detect it with the share of cost events where trace_id is null, per client.
  6. Budgets nobody can explain. A tenant budget trips and the operator cannot see which tool or user drove it. Detect it in the drill: can you name the top three tools behind any tenant’s last budget alert in under five minutes?

Implementation Checklist

  • Every tools/call emits one cost event with tool, tenant, trace_id, downstream_calls, response_bytes, est_tokens, duration_ms and retry_of.
  • The tenant on the event comes from the verified token, never from arguments or headers.
  • Traced and untraced traffic are reported separately, and heuristic workflow groupings are labelled.
  • Forecasts use the top tenant’s measured volume and retry rate, not the average.
  • Budgets exist at the client, server and gateway layers, and the gateway’s tool firewall doubles as the kill switch.
  • Each tenant’s owners see a monthly showback by tool.

What Comes Next

Cost is feedback on design quality: when every call carries its tenant, tool and trace, a surprising bill becomes a query instead of an argument. The most common reason those numbers change is that someone changed the tool surface: a new tool, a renamed one, a richer result. Chapter 28 is how to make those changes to a live server without breaking the callers, or the forecasts, that depend on it.