The APIVerve MCP server can hand an agent every endpoint in the catalog, over three hundred tools, in one line of config. That is the right setup for exploring in a desktop assistant, where a person is watching and the question changes every minute. It is the wrong setup for a production agent with a job. A support bot that books meetings does not need a maze generator, and every tool it can see is a tool it can pick by mistake.
Tool design is the part of agent building with the biggest payoff per line of code, and it is mostly about saying no.
01Why fewer tools work better
Three costs grow with the size of the tool list, and none of them is obvious from a demo.
Selection errors. The model chooses a tool by reading names and descriptions. With five tools, each with a distinct job, it rarely picks wrong. With three hundred, there are dozens of near-neighbours: World Time and Timezone Lookup both return the time in a city, Exchange Rate and Currency Converter both return a rate. When two tools could plausibly answer, the model will sometimes pick the one you did not test, with parameters you did not expect.
Tokens on every request. Tool definitions are sent with every request in the loop, and they count as input. A long list is paid for on turn one and again on every later turn, whether or not any of those tools gets called. Some model APIs can defer loading or search tool definitions, which helps, but the cheapest definition is the one you never send.
A wider blast radius. An agent can only misuse tools it has. If a prompt injection in a scraped page tells it to render a thousand screenshots, the agent that only has a time tool and a currency tool cannot comply.
The rule of thumb: start from the agent's job, list the questions it must answer, and give it exactly the tools those questions need. Add one when a real conversation shows a gap, not when it seems like it might be handy.
Pick one tool per job
Near-duplicates are the most common source of wrong choices, so resolve them before the model has to. Here are two pairs from the catalog that overlap:
| Job | Option A | Option B | Pick when |
|---|---|---|---|
| Time in a place | World Time (city) | Timezone Lookup (city or timezone) | A when users name cities; B when you already hold an IANA zone |
| Money between currencies | Exchange Rate (rate only) | Currency Converter (amount, rate, trend) | B when users talk in amounts; A for a bare rate |
curl "https://api.apiverve.com/v1/timezonelookup?city=Chicago" \
-H "x-api-key: YOUR_API_KEY"One object rather than a list of matches, with timezone_offset in minutes.
Timezone Lookup returns a single object with timezone and timezone_offset (we got America/Chicago and -300 for Chicago in October) plus the local date and time24. It also accepts an IANA zone name instead of a city (timezone=America/New_York gave -240), which makes it the better tool when the agent is scheduling across offices whose zones you already store. When users type city names into a chat box and you want to see every match, World Time is.
curl "https://api.apiverve.com/v1/currencyconverter?value=250&from=USD&to=JPY" \
-H "x-api-key: YOUR_API_KEY"convertedValue does the multiplication for you. change24h and the high/low fields are premium.
For a travel or invoicing assistant, Currency Converter answers in the user's terms: value, convertedValue, rate, plus date and lastUpdated for provenance. The arithmetic happens in the API rather than in the model, which removes a class of errors entirely. Expose one of the two, not both.
Your agent has both Exchange Rate and Currency Converter as tools. Users report it sometimes multiplies amounts itself and gets the decimals wrong. What is the best fix?
02Names and descriptions are the interface
The model never sees your code. It sees a name, a description and an input schema, and decides from those alone. Write them the way you would write a function signature and docstring for a colleague who will never be able to ask you a question.
Names. Use a verb and an object in your own domain: get_local_time, convert_currency, check_email_deliverable. Avoid naming the vendor or the endpoint (apiverve_worldtime): the model does not care where the data comes from, and you may swap the backend later. Keep names distinct enough that two tools never differ only by a suffix.
Descriptions. A good tool description answers four questions in two to four sentences:
- What does it return, concretely? Name the fields and their units or formats.
- When should the model use it? The trigger, in terms of the user's question.
- When should it not? The nearest wrong use, if there is one.
- What does it cost or how slow is it, if that should affect the choice?
The APIVerve MCP server appends each endpoint's credit cost to its tool description for exactly the fourth reason: an agent choosing between two ways to answer can pick the cheaper one. Do the same in your own wrappers when costs differ a lot.
Parameter descriptions carry formats and examples: "ISO 4217 code, e.g. USD", "YYYY-MM-DD". A model that sees an example almost always matches its format.
Strict schemas, because extra fields are ignored
APIVerve ignores parameters it does not declare rather than rejecting them. That is friendly for humans and dangerous for agents: if the model invents a country argument for a time lookup and your wrapper passes it through, the call succeeds and the extra argument does nothing. You get an answer for the wrong Springfield, with no error to tell you why.
Two defences. In the schema, set additionalProperties: false and list required fields; Anthropic and OpenAI both offer a strict mode that makes the model's arguments conform to the schema exactly. In your wrapper, build the outgoing parameters yourself from known fields instead of forwarding whatever arrived.
03Wrap the raw API when its shape would mislead
Exposing an API one-to-one as a tool is fine when its input is unambiguous and its output is small. Many are not, and a thin wrapper in your server fixes that. Look at what World Time does with a common city name:
curl "https://api.apiverve.com/v1/worldtime?city=Springfield" \
-H "x-api-key: YOUR_API_KEY"Five matches across Oregon, Massachusetts, Ohio, Illinois and Missouri, in three timezones.
We got five cities: Oregon (Pacific), Massachusetts and Ohio (Eastern), Illinois and Missouri (Central). A raw tool hands the model the entire array, around two kilobytes of JSON including premium fields it may not have on your plan, and trusts it to notice the ambiguity. Often it does not; it reads the first entry and answers. The lesson one code made the same mistake in code by taking foundCities[0].
A wrapper turns that into a decision the model can act on:
- Accept optional
regionandcountry_codearguments so the model can disambiguate when the user said "Springfield, Illinois". - Filter the matches on the free fields
provinceandiso2. - If the remaining matches all share one timezone, the answer is the same for all of them: return it.
- If they span several timezones, return
ambiguous: trueand a short candidate list, and say in the description that the model should ask the user. - Return five fields, never the whole response.
// Node 18+, ESM. A tool registry: definition and implementation side by side.
async function apiverve(id, params) {
const url = `https://api.apiverve.com/v1/${id}?${new URLSearchParams(params)}`;
const res = await fetch(url, { headers: { 'x-api-key': process.env.APIVERVE_API_KEY } });
const body = await res.json();
if (body.status !== 'ok') throw new Error(`${id} ${res.status}: ${body.error}`);
return body.data;
}
export const TOOLS = {
get_local_time: {
definition: {
name: 'get_local_time',
description:
'Current local time and date in a city, from a live clock. Returns city, region, timezone, time24 (HH:MM:SS) and date (YYYY-MM-DD). ' +
'If the result has ambiguous=true, the city name matches places in different timezones: ask the user which one, listing the candidates. Do not guess.',
strict: true,
input_schema: {
type: 'object',
additionalProperties: false,
properties: {
city: { type: 'string', description: 'City name only, e.g. "Springfield"' },
region: { type: 'string', description: 'State or province if the user gave one, e.g. "Illinois". Empty string if not.' },
country_code: { type: 'string', description: 'ISO 3166-1 alpha-2 code if known, e.g. "US". Empty string if not.' },
},
required: ['city', 'region', 'country_code'],
},
},
async run({ city, region, country_code }) {
const data = await apiverve('worldtime', { city }); // only declared params go out
let matches = data.foundCities;
if (country_code) matches = matches.filter((c) => c.iso2 === country_code.toUpperCase());
if (region) matches = matches.filter((c) => (c.province ?? '').toLowerCase() === region.toLowerCase());
if (!matches.length) return { found: false, message: `No match for ${city} with those filters.` };
const zones = new Set(matches.map((c) => c.timezone));
if (zones.size > 1) {
return {
ambiguous: true,
candidates: matches.slice(0, 6).map((c) => ({ city: c.city, region: c.province, country: c.iso2, timezone: c.timezone })),
};
}
const c = matches[0];
return { city: c.city, region: c.province, timezone: c.timezone, time24: c.time24, date: c.date };
},
},
convert_currency: {
definition: {
name: 'convert_currency',
description:
'Convert an amount between two currencies at the live market rate. Returns convertedValue, rate, and date/lastUpdated for the rate. ' +
'Use this for any money conversion; do not multiply rates yourself.',
strict: true,
input_schema: {
type: 'object',
additionalProperties: false,
properties: {
amount: { type: 'number', description: 'Amount in the source currency, e.g. 250' },
from: { type: 'string', description: 'ISO 4217 code, e.g. "USD"' },
to: { type: 'string', description: 'ISO 4217 code, e.g. "JPY"' },
},
required: ['amount', 'from', 'to'],
},
},
async run({ amount, from, to }) {
const d = await apiverve('currencyconverter', { value: amount, from: from.toUpperCase(), to: to.toUpperCase() });
return { from: d.from, to: d.to, amount: d.value, convertedValue: d.convertedValue, rate: d.rate, date: d.date, lastUpdated: d.lastUpdated };
},
},
};
export const definitions = Object.values(TOOLS).map((t) => t.definition);# Python 3.9+. A tool registry: definition and implementation side by side.
import os
import requests
def apiverve(api_id, params):
r = requests.get(
f"https://api.apiverve.com/v1/{api_id}",
params=params,
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]},
timeout=15,
)
body = r.json()
if body.get("status") != "ok":
raise RuntimeError(f"{api_id} {r.status_code}: {body.get('error')}")
return body["data"]
def get_local_time(city, region="", country_code=""):
matches = apiverve("worldtime", {"city": city})["foundCities"]
if country_code:
matches = [c for c in matches if c.get("iso2") == country_code.upper()]
if region:
matches = [c for c in matches if (c.get("province") or "").lower() == region.lower()]
if not matches:
return {"found": False, "message": f"No match for {city} with those filters."}
if len({c["timezone"] for c in matches}) > 1:
return {
"ambiguous": True,
"candidates": [
{"city": c["city"], "region": c.get("province"), "country": c.get("iso2"), "timezone": c["timezone"]}
for c in matches[:6]
],
}
c = matches[0]
return {"city": c["city"], "region": c.get("province"), "timezone": c["timezone"], "time24": c["time24"], "date": c["date"]}
def convert_currency(amount, from_, to):
d = apiverve("currencyconverter", {"value": amount, "from": from_.upper(), "to": to.upper()})
keys = ("from", "to", "value", "convertedValue", "rate", "date", "lastUpdated")
return {k: d.get(k) for k in keys}
TOOLS = {
"get_local_time": {
"run": lambda a: get_local_time(a["city"], a.get("region", ""), a.get("country_code", "")),
"definition": {
"name": "get_local_time",
"description": (
"Current local time and date in a city, from a live clock. Returns city, region, timezone, "
"time24 (HH:MM:SS) and date (YYYY-MM-DD). If the result has ambiguous=true, the city name matches "
"places in different timezones: ask the user which one, listing the candidates. Do not guess."
),
"strict": True,
"input_schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"city": {"type": "string", "description": 'City name only, e.g. "Springfield"'},
"region": {"type": "string", "description": 'State or province if given, e.g. "Illinois". Empty string if not.'},
"country_code": {"type": "string", "description": 'ISO 3166-1 alpha-2 code if known, e.g. "US". Empty string if not.'},
},
"required": ["city", "region", "country_code"],
},
},
},
"convert_currency": {
"run": lambda a: convert_currency(a["amount"], a["from"], a["to"]),
"definition": {
"name": "convert_currency",
"description": (
"Convert an amount between two currencies at the live market rate. Returns convertedValue, rate, "
"and date/lastUpdated for the rate. Use this for any money conversion; do not multiply rates yourself."
),
"strict": True,
"input_schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"amount": {"type": "number", "description": "Amount in the source currency, e.g. 250"},
"from": {"type": "string", "description": 'ISO 4217 code, e.g. "USD"'},
"to": {"type": "string", "description": 'ISO 4217 code, e.g. "JPY"'},
},
"required": ["amount", "from", "to"],
},
},
},
}
DEFINITIONS = [t["definition"] for t in TOOLS.values()]Here is what the model receives for a bare "Springfield" now, instead of five full city records:
{
"ambiguous": true,
"candidates": [
{ "city": "Springfield", "region": "Oregon", "country": "US", "timezone": "America/Los_Angeles" },
{ "city": "Springfield", "region": "Massachusetts", "country": "US", "timezone": "America/New_York" },
{ "city": "Springfield", "region": "Ohio", "country": "US", "timezone": "America/New_York" },
{ "city": "Springfield", "region": "Illinois", "country": "US", "timezone": "America/Chicago" },
{ "city": "Springfield", "region": "Missouri", "country": "US", "timezone": "America/Chicago" }
]
}
Plug definitions into the tools parameter of the lesson one loop and dispatch with TOOLS[block.name].run(block.input). Asked "What time is it in Springfield?", the model now gets back a three-zone candidate list and asks which Springfield you mean. Asked about "Springfield, Illinois", it passes region and gets one answer.
Notice what the wrapper did not do: it did not try to be clever about which Springfield is "most likely". Guessing on the user's behalf is the failure we are removing. When the ambiguity matters, the person who knows the answer is the user.
A raw worldtime tool returns all five Springfield matches. What is the main risk of giving that array straight to the model?
04Scope access, not just the tool list
Choosing tools is half of scoping. The other half is making sure the credentials behind them cannot do more than the tools need, because a bug or an injected instruction can reach anything the key can.
If your agent runs through MCP rather than your own loop, you can narrow the server itself. Appending an endpoint id gives you a server with exactly one tool, for example https://api.apiverve.com/v1/mcp/emailvalidator, with the same key and billing. That is the right shape for a single-purpose agent; see the MCP setup guide.
Either way, give the agent a sub-key rather than your primary key, so you can revoke it without touching anything else, and block the endpoints it has no business calling with key scoping. Scoping is a deny-list, so new catalog endpoints are reachable by default; revisit it when you change what the agent does.
Next: Handle tool errors in agents, where these wrappers learn to fail in ways the model can recover from, and the loop learns when to stop.