Zuplo
API Monetization

Charge Agents for MCP Tool Calls

Moritz SchworerMoritz Schworer
July 14, 2026
6 min read

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.

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.

No MCP server yet?

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.

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.

JSONjson
{
  "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.

Common 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.

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, 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 (-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:

TypeScripttypescript
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 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.

Try it yourself

weather-mcp

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.

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.