We cut our MCP server's token bill by 95%. The tool count had nothing to do with it
Finding one payment cost an AI agent 51,561 tokens. Now it costs 832. How we measured our MCP server, and the four changes that cut its token use by 95%.
An AI agent asked our MCP server to find one customer's payment. To do it, it read all 300 payments on the account: 4 tool calls and 51,561 tokens, and it still couldn't see the customer's name. After four small changes, the same question takes 1 call and 832 tokens.
We went in expecting the problem to be the number of tools we expose. It wasn't. This post covers how we measured what our tools really cost, the four changes that cut the total by 95%, and the one that mattered more than the other three put together: fixing the API, not the MCP server.
Then we did cut the tool count, from 17 to 5. It saved nothing on these questions. What it did was let the AI see three times more of the account, and answer more than twice as many questions.
95% fewer tokens to answer the same five questions: 94,323 down to 4,548.
The problem
Bachs is a payments platform for internet businesses. We built an MCP server so merchants can ask Claude or ChatGPT about their own account: their payments, customers, subscriptions and products. It sits on top of our existing REST API and has 15 tools.
The first question we tried was "find Ada Obi's payment". The agent called the list payments tool, got a page, didn't find Ada, asked for the next page, and kept going. Our first guess was that 15 tools was too many and the descriptions were eating the context. So before changing anything, we measured.
How we measured
We wrote a script that runs five questions a merchant might ask against a test account with 300 payments, 40 customers, 60 subscriptions and 12 products:
- Find Ada Obi's payment.
- Show my recent payments.
- What subscriptions does Ada have?
- List my products.
- Which payments failed this week?
For each question, the script records how many tool calls the agent makes and how many characters each answer puts into the model's context. We estimate tokens as characters divided by 3.5. It's not exact, but it's consistent, and consistency is what you need to compare one version with the next.
The baseline: 9 calls and 94,323 tokens across the five questions.
Step 1: compact JSON (−9%)
Our tools returned pretty-printed JSON, because it's easier to read in logs. Models don't need the indentation, and it was a third of every answer. Removing it was a one-line change:
// Before: indentation and newlines on every answer.
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
// After
return { content: [{ type: "text", text: JSON.stringify(result) }] }; The total dropped 9%. The search question barely moved, though: every page it read was still large enough to hit our 60,000-character cap on a single answer. The answers were smaller, but the agent was still reading far too many of them.
Step 2: slim list rows (−63%)
A row in our payments list is a full payment: amounts paid and remaining, the fee, VAT, the settlement amount and currency, metadata and two timestamps. That's what an API client wants. It's not what an agent needs to pick a payment out of a list.
{"id":"chrg_1a2b3c4d5e","reference":"order_9876","status":"succeeded","is_refundable":true,"amount":"10.00","customer_name":"Jane Doe","customer_email":"customer@example.com","amount_paid":"10.00","amount_remaining":"0.00","settlement_amount":"10.00","fee":"0.90","vat":null,"currency":"USD","settlement_currency":"USD","meta":null,"transaction_date":"2026-04-27T12:00:00Z","completed_at":"2026-04-27T12:00:05Z"} To choose a payment, the agent needs five things: the id, the status, the amount, who paid and when. Everything else belongs in the tool that gets one payment by id, where the agent asks for it on purpose.
// A list row has what you need to pick a payment.
// Fees, settlement and metadata stay in the get call.
function slimPayment(payment: Row): Row {
return compact({ // drops empty fields: an absent field costs nothing
id: payment.id,
status: payment.status,
amount: payment.amount,
currency: payment.currency,
customer_name: payment.customer_name,
customer_email: payment.customer_email,
reference: payment.reference,
created_at: payment.transaction_date ?? payment.created_at,
failure: payment.failure_message ?? payment.substatus,
});
} Lists also now default to 20 rows and say where the next page starts, instead of returning as much as fits. Together, these cut the total by 63%. It was the biggest drop overall, because every list question benefits.
Step 3: fix the API, not the MCP server (−86%)
Slim rows made each page cheaper, but "find Ada's payment" still meant reading pages until Ada turned up. The real problem was underneath the MCP server: our list payments endpoint could only filter by status. There was no way to ask the API for Ada's payments, so there was no way for any tool to ask either.
No trick inside an MCP server fixes that. So we fixed the API. GET /v1/payments now supports search and filters: customer name, email, reference, payment id, customer, currency, amount and date. The database does the matching, so the agent never reads rows it doesn't need.
Finding Ada's payment went from 4 calls and 51,561 tokens to 1 call and 773 tokens. The total for all five questions fell another 86%.
There's a side effect worth saying out loud: every API customer gets the new filters, not just the AI. The change we made for the agent made the API better for everyone.
Step 4: show what the merchant needs (+4%)
With search working, we found a different problem. Our list rows masked customer names, so even when the agent found the right payment, it couldn't recognise it as Ada's.
We now show customer names, emails and phone numbers, as Stripe's MCP server does. They're the merchant's own customers, and the merchant sees them in the dashboard already. Bank, card and wallet numbers keep only their last four digits.
This made answers 4% bigger. It's a small cost for answers a merchant can actually use, and the right trade.
The results
Across the five questions, the total went from 94,323 tokens to 4,548, and from 9 calls to 5. Most of the drop came from two changes: slim rows, and search in the API.
Each question, before and after
The question that read every payment now takes one call.
- Find Ada Obi's payment Before: 4 calls, 51,561 tokensAfter: 1 call, 832 tokensFewer tokens: 98%
- What subscriptions does Ada have? Before: 2 calls, 17,237 tokensAfter: 1 call, 832 tokensFewer tokens: 95%
- Which payments failed this week? Before: 1 call, 14,643 tokensAfter: 1 call, 898 tokensFewer tokens: 94%
- Show my recent payments Before: 1 call, 6,836 tokensAfter: 1 call, 1,499 tokensFewer tokens: 78%
- List my products Before: 1 call, 4,047 tokensAfter: 1 call, 487 tokensFewer tokens: 88%
What didn't matter as much as we thought
The number of tools, for cost. Our tool descriptions cost about 2,900 tokens, and the model pays that once per conversation. One unfiltered list of this week's payments cost 14,643. The descriptions grew from 2,062 tokens to 2,903 as we documented the new filters. That was worth watching, but it was never the problem. Tool count did matter later, for a different reason: what the AI could see at all.
The number of calls. Calls fell from 9 to 5, while tokens fell by 95%. Fewer calls is nice, but the size of each answer was the lever.
Step 5: five tools for the whole account
Our 15 account tools wrapped a hand-written SDK, and that SDK covered about 16 endpoints. So the AI couldn't see compliance, disputes, refunds, request logs or webhooks. A merchant asking "why can't I get paid out?" got nothing useful, however cheap the answer was.
We dropped the SDK and now call the API directly. The 15 tools became three: search, list and get. List and get take a record type, and one table names every type with its path, the permission it needs and the filters it takes:
// Every kind of record list can read: its path, the permission it needs, its filters.
export const LIST_TYPES = {
payments: {
scope: 'payments:read',
path: '/v1/payments',
filters: { status_filter: text, search, customer_id: recordId, currency,
amount_min: money, amount_max: money, date_from: from, date_to: to },
slim: slimPayment
},
refunds: { scope: 'refunds:read', path: '/v1/refunds', filters: { status: text } },
disputes: { scope: 'disputes:read', path: '/v1/disputes', filters: { status: text, from_date: from, to_date: to } },
// ...and 20 more
}; Each tool costs every conversation its own title, schema and boilerplate. The list description names 23 types in a line each. So the descriptions got a third smaller, while the reach grew more than three times:
Fewer tools, more of the account
Three tools replaced fifteen. The descriptions got smaller and the reach grew three times.
- ToolsIncluding the 2 docs tools Step 4: 17Step 5: 5
- API endpoints the model can read Step 4: About 16Step 5: 53
- Tool descriptionsPaid once per conversation Step 4: 2,903 tokensStep 5: 1,879 tokens
- Tokens for the five questions Step 4: 4,548Step 5: 4,548
- Merchant questions it can answer Step 4: 7 of 15Step 5: 15 of 15
The five questions cost exactly the same. The same rows come back, through list instead of list_payments. A wrong filter comes back as an error that names the right ones, so a model can correct itself in one call.
Fifteen questions across the whole account
The five questions only touch payments, customers, subscriptions and products. So we added ten more that need the new types: balances, payouts, disputes, request logs, webhooks, refunds, invoices, payment health and compliance. An answer only counts if it holds the fact the question is about. For "why did my last payout fail?", the reason has to be in it, not just the payout.
All 15 were answered, in 16 calls and 11,582 tokens: about 770 tokens a question. Before step 5, the server could answer 7 of them.
We also compared each answer with what a server would put in context if it passed the API's JSON straight through. Over the 13 questions that aren't searches, that's 23,922 tokens against our 9,918: 59% fewer.
The server adds about 3 ms a call. Over 420 timed calls on one machine, a call through it took 3.9 ms at the median, against 0.7 ms calling the API directly. In production, the network and the API's own work dwarf that.
Does a model actually use it well?
A script that answers each question the cheapest way shows what the tools cost. It doesn't show whether a model finds that way by itself. So we gave 8 Claude agents the tool definitions and 20 merchant questions, and nothing else. Four ran on Sonnet and four on Haiku, the smallest Claude model, with 5 questions each. They couldn't read our code or the data, and every answer was checked against what the test account really holds.
Two models, with nothing but the tool definitions
Both answered all 20 questions. The smaller model checked more on the way.
- Answered correctly Sonnet: 20 of 20Haiku: 20 of 20
- Tool calls1.15 and 1.65 a question Sonnet: 23Haiku: 33
- Answered in one call Sonnet: 19 of 20Haiku: 12 of 20
- Calls that failed3 of Haiku's were gaps in the test account Sonnet: 0Haiku: 4
- Tokens of resultsTool definitions add 1,879 to each Sonnet: About 11,600Haiku: About 12,700
Sonnet needed a second call only once. Asked why an API call failed today, it listed the logs, then read all three failed calls. Our answer key only had the first, so its answer was better than ours.
Haiku got there by checking more: it often read a single refund or payment after listing them, where Sonnet answered from the list. Three of its four failed calls asked for things the test account doesn't have. The fourth was a real miss. It called get on a type only list serves, and our error named every get type without saying "use list".
This was a test account and a command-line client, not Claude's own MCP client. It's good evidence the tool design works, not a leaderboard score.
What's next
Three things. The new lists aren't slimmed yet: a row of refunds or webhook events costs 84 to 105 tokens, against 75 for a payment and 41 for a product. Errors should name the right tool, not just the right type. And a contract test now checks all 53 record types against the backend's own routes, so the catalog can't drift from the API without us knowing.
Three rules we'd give anyone building an MCP server
- Measure the answers before the descriptions. Run real questions and count what lands in the context. The expensive part is rarely where you'd guess.
- Make list rows small and get calls complete. A list is for choosing. Details come from the get call, when the agent asks for them.
- If the API can't answer the question, change the API. An MCP server can only be as good as the endpoints under it, and the fix helps every API customer too.