---
title: "Charge Agents for MCP Tool Calls"
description: "Your MCP server is live and agents call it for free. Authenticate each agent with an API key, then meter only the tool calls so protocol chatter stays free. Enforce each plan's quota and let Stripe bill the rest."
canonicalUrl: "https://zuplo.com/blog/2026/07/14/charge-agents-for-mcp-tool-calls"
pageType: "blog"
date: "2026-07-14"
authors: "mo"
tags: "API Monetization, Model Context Protocol, ai-agents"
image: "https://zuplo.com/og?text=Charge%20Agents%20for%20MCP%20Tool%20Calls"
---
Every tool call an agent makes costs you compute, and right now none of it comes
back as revenue. Monetizing comes down to two questions: which agent is calling,
and what have they paid for? Both are answered by one credential the agent
already carries, an API key.

OAuth is how a person clicks Allow in Claude Desktop. An agent running
unattended has nobody to click anything; it needs a credential it can hold and
you can bill. In Zuplo that credential is an API key tied to a subscription, and
a small pair of policies turns it into metered, billable access.

<CalloutDoc
  title="No MCP server yet?"
  description="Zuplo's Dynamic OpenAPI to MCP Server turns an OpenAPI spec into one: add the handler on POST /mcp, pick which operations become tools, and you have a server to put these policies in front of. This post picks up from there."
  href="https://zuplo.com/docs/articles/mcp-quickstart"
  icon="book"
/>

## Authenticate agents with an API key

Your MCP server is a Zuplo route, so it takes the same inbound policies as any
other. Add `monetization-inbound`: it authenticates the caller's API key,
resolves the subscription behind it, and hands that subscription to the policies
that follow through `MonetizationInboundPolicy.getSubscriptionData()`. Leave its
`meters` option empty: you will decide what to charge in code, not on the way
in.

```json
{
  "paths": {
    "/mcp": {
      "post": {
        "x-zuplo-route": {
          "handler": {
            "export": "mcpServerHandler",
            "module": "$import(@zuplo/runtime)"
          },
          "policies": {
            "inbound": ["monetization-inbound", "mcp-monetization-inbound"],
            "outbound": ["mcp-monetization-outbound"]
          }
        }
      }
    }
  }
}
```

The agent presents its key as a bearer token, `Authorization: Bearer <key>`, set
once in its MCP client config. From then on every call carries the subscription.

<CalloutTip variant="mistake">
  You do not need `api-key-inbound` on this route. `monetization-inbound`
  authenticates the key itself, so stacking a separate key policy in front of it
  is redundant and an easy way to end up debugging double auth.
</CalloutTip>

## Meter the tool calls, not the protocol

The trap: every request an MCP client sends is a `POST /mcp` that comes back
`200`. Put `meters: { "tool_calls": 1 }` on `monetization-inbound` and it bills
every one of them, so the agent gets charged just for discovering your tools.
Only one method should ever draw down a meter:

| JSON-RPC method   | Billed?             |
| :---------------- | :------------------ |
| `initialize`      | No                  |
| `tools/list`      | No                  |
| `ping`            | No                  |
| `notifications/*` | No (bodyless `202`) |
| `tools/call`      | Yes                 |

On connect an agent fires `initialize`, then `tools/list`, before it ever calls
a tool, and a long-lived session keeps sending `ping`, so protocol frames
outnumber the billable calls. Metering only `tools/call` keeps that chatter
free.

The metering decision lives in a custom inbound policy that reads the JSON-RPC
body. Protocol methods pass through free; only `tools/call` is a candidate to
bill. The subscription is already loaded, so the same policy checks the plan's
entitlement and quota and rejects the call before the tool ever runs:

```typescript
import {
  MonetizationInboundPolicy,
  ZuploContext,
  ZuploRequest,
} from "@zuplo/runtime";

// Which metered bucket each billable tool draws from.
const TOOL_METERS: Record<string, string> = {
  get_current_weather: "tool_calls",
  get_forecast: "tool_calls",
  get_weather_alerts: "tool_calls",
  get_historical_weather: "tool_calls",
  get_radar: "radar",
  get_station_observations: "station_observations",
};

const rpcError = (id: unknown, code: number, message: string) =>
  new Response(
    JSON.stringify({
      jsonrpc: "2.0",
      id: id ?? null,
      error: { code, message },
    }),
    { status: 200, headers: { "Content-Type": "application/json" } },
  );

export default async function (request: ZuploRequest, context: ZuploContext) {
  // Clone so the MCP handler still receives the body intact.
  const body = await request.clone().json();

  // initialize, tools/list, ping, notifications … discovery is free.
  if (body.method !== "tools/call") return request;

  const tool = body.params?.name as string;
  const meter = TOOL_METERS[tool];
  if (!meter) return request; // unknown tool, let the handler say so

  const sub = MonetizationInboundPolicy.getSubscriptionData(context);
  const entitlement = sub?.entitlements[meter];

  if (!entitlement) {
    // No entitlement for the bucket → the tool isn't in this plan at all.
    return rpcError(body.id, -32003, `"${tool}" is not included in your plan.`);
  }
  if (!entitlement.hasAccess || entitlement.balance <= 0) {
    // Included, but the monthly allowance is spent.
    return rpcError(
      body.id,
      -32004,
      `Monthly "${meter}" quota is exhausted; it resets next month.`,
    );
  }

  // Allowed. Remember the bucket; the outbound policy commits it on success.
  context.custom.mcpMeter = meter;
  return request;
}
```

Notice the rejection is a JSON-RPC error at `200`, not a `403`. MCP speaks
JSON-RPC over its
[Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports),
so a bare `403` reaches the agent as a transport failure it can't read, while a
JSON-RPC `error` carries a message it can.

The codes are custom values inside JSON-RPC's
[reserved server-error range](https://www.jsonrpc.org/specification#error_object)
(`-32003` for "not on your plan," `-32004` for "quota spent"), and like a `403`
rather than a `429`, they tell the agent that retrying won't help until the
customer upgrades or the month resets.

## Bill only on success

MCP returns `200` even when a call fails, either as a top-level JSON-RPC `error`
or as a tool result flagged `isError: true` when the upstream API breaks.
Committing the meter on the way out lets you charge only for calls that actually
worked:

```typescript
import { MonetizationInboundPolicy, ZuploContext } from "@zuplo/runtime";

// MCP's Streamable HTTP transport answers POST /mcp with either application/json
// or text/event-stream. Read the JSON-RPC message out of whichever we got.
async function readRpcMessage(response: Response) {
  const clone = response.clone();
  const contentType = clone.headers.get("content-type") ?? "";

  if (contentType.includes("text/event-stream")) {
    // SSE frames look like `event: message\ndata: {json}\n\n`; pull the data payload.
    const text = await clone.text();
    const data = text
      .split("\n")
      .filter((line) => line.startsWith("data:"))
      .map((line) => line.slice("data:".length).trim())
      .join("");
    return data ? JSON.parse(data) : {};
  }

  return clone.json();
}

export default async function (
  response: Response,
  _request: ZuploRequest,
  context: ZuploContext,
) {
  const meter = context.custom.mcpMeter as string | undefined;
  if (!meter) return response; // a free method, or an already-rejected call

  const message = await readRpcMessage(response);
  if (message.error || message.result?.isError) return response; // failed, bill nothing

  // Clean success: commit one unit to the bucket the inbound policy chose.
  MonetizationInboundPolicy.setMeters(context, { [meter]: 1 });
  return response;
}
```

A bad coordinate, an upstream outage, a call the plan didn't allow: none of them
cost the customer anything. Only a tool that ran and returned a real result
draws down the meter.

## Give different tools different buckets

One meter is the simple case, but real tools cost different amounts to serve, so
a plan can carry several buckets and route each tool to the one it belongs in:

| Tool                                                        | Bucket                 | Gate                        |
| :---------------------------------------------------------- | :--------------------- | :-------------------------- |
| `get_current_weather`, `get_forecast`, `get_weather_alerts` | `tool_calls`           | shared pool                 |
| `get_historical_weather`                                    | `tool_calls`           | boolean `historical_access` |
| `get_radar`                                                 | `radar`                | dedicated pool              |
| `get_station_observations`                                  | `station_observations` | dedicated pool              |

Gating falls out of the entitlements for free. A plan that doesn't grant the
`radar` bucket has no `radar` entitlement, so the check above rejects the call,
with no plan-to-tool table to maintain.

The historical archive is the exception: it draws from the shared `tool_calls`
pool, so a bucket alone can't gate it. It carries an extra boolean feature,
`historical_access`, that only the paid plans include, so check
`sub.entitlements.historical_access?.hasAccess` before you charge.

## Issue keys when customers subscribe

You don't issue keys by hand. When a customer subscribes to a plan in your
developer portal, Zuplo issues a plan-scoped API key for them, and the plan's
rate card sets each bucket's monthly quota. The same key that identifies the
agent carries how much it is allowed to spend, so a free tier and a high-volume
tier are just two plans pointing at the same server. Building the plans, rate
cards, and Stripe side behind those keys is its own task;
[Building a Monetized API](/blog/building-a-monetized-api-part-2) walks through
it.

## Let Stripe invoice the usage

At the end of the billing period, Zuplo issues a Stripe invoice per subscriber:
the plan's fixed fee plus whatever each bucket counted. You do not push usage
records or reconcile two systems. The same units the outbound policy committed
land on the invoice.

<CalloutSample
  title="weather-mcp"
  description="The sample this post is modeled on: a weather MCP server whose repo documents the two custom monetization policies, the three buckets, and the historical-access gate end to end. Clone it as your starting point."
  repoUrl="https://github.com/zuplo-poc/weather-mcp"
/>

Before, an open MCP server anyone could call for free. After, every agent
authenticates with an API key, every successful tool call draws down the right
bucket, and Stripe bills each customer for exactly what their agents used. For
the plans-and-entitlements side in full, read
[how to monetize an MCP server](/blog/monetize-an-mcp-server).