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