When an agent misuses a tool, the instinct is to blame the model. Look at the tool definition first. The model chose that tool, built those arguments and interpreted that result using nothing but the name, the descriptions and the schemas it was given. If the definition is ambiguous, the behaviour will be too, and no amount of system-prompt pleading fixes a schema that says the wrong thing.
This lesson reads a real APIVerve tool definition the way a model does, then looks at the other half of the contract: what comes back, how structuredContent differs from the text block, and why a null is information rather than an absence of it.
01A tool definition, field by field
Here is the complete definition the APIVerve server returns for Number to Words, as tools/list sends it. The output schema is trimmed to a few fields; the rest follow the same pattern.
{
"name": "NumbertoWords",
"title": "Number to Words",
"description": "Number to Words: Number to Words is a number to words API that converts numeric values into written cardinal words and counts whole and fractional digits. Pass any integer or float to get the spelled-out text along with digit counts and locale information. (Cost: 1 credit)",
"inputSchema": {
"type": "object",
"properties": {
"number": {
"type": "number",
"description": "The number to convert to words",
"examples": [975.07]
}
},
"required": ["number"]
},
"outputSchema": {
"type": "object",
"properties": {
"status": { "type": "string", "description": "\"ok\" on success" },
"error": { "type": ["string", "null"], "description": "Error message, null on success" },
"data": {
"type": "object",
"properties": {
"words": { "type": ["string", "null"], "description": "The cardinal word representation of the number" },
"ordinal": { "type": ["string", "null"], "description": "The ordinal word representation of the number (premium)" },
"afterDecimal": {
"type": "object",
"description": "Breakdown of the digits after the decimal point, or null when the number has no fractional part"
}
}
}
}
},
"annotations": { "readOnlyHint": true, "openWorldHint": true }
}
Each part has a job.
name is the identifier the model emits when it wants the tool. APIVerve derives it from the endpoint title stripped to letters, digits, underscores and hyphens, so Number to Words becomes NumbertoWords. Names must be stable: a client may cache them, and a user may have approved a tool by name.
title is for humans. Clients that render a tool picker show it; the model mostly ignores it.
description is the single most important string in the definition, because it is what the model reads to decide whether this tool answers the question in front of it. Note the cost appended at the end. An agent choosing between a 1-credit and a 20-credit way to answer has what it needs to pick the cheap one.
inputSchema is a JSON Schema for the arguments object. type: "number" means the model should send 975.07, not "975.07" or "nine hundred". required tells it which arguments it cannot leave out. examples shows a valid value, which helps more than people expect: a model that has seen one correctly formatted example rarely invents a different format.
outputSchema describes what a successful result contains. It arrived in protocol version 2025-06-18, so a client negotiating an older version will not see it. Look at the types: almost every field is ["string", "null"] or ["number", "null"]. That is deliberate, and it is the subject of the last section.
annotations are hints about behaviour. readOnlyHint: true says calling the tool changes nothing on your side, which is true of every APIVerve tool: they look things up, compute or generate. openWorldHint: true says the tool reaches outside a closed system. Clients may use these to decide when to ask for confirmation. The specification is clear that annotations are hints, and a client should not trust them from a server it does not trust. They are a courtesy, not a security boundary.
Which part of a tool definition does the model rely on most to decide whether to call that tool for a given question?
02Why precise descriptions change behaviour
A model is a careful reader of whatever you give it, and a careless reader of whatever you do not. Field descriptions are where that shows most clearly, because field names are short and descriptions are not.
Take Email Validator. Run it against an address with a familiar misspelling:
curl "https://api.apiverve.com/v1/emailvalidator?email=jane%40gmial.com" \
-H "x-api-key: YOUR_API_KEY"Compare hasTypo with suggestedCorrection, and isMxValid with canReceiveMail.
Two pairs in that response would trip up a model that reads only the names.
hasTypo is false, but there is obviously a typo. The field's description in the schema reads: "The address is malformed and fails format validation." It is a syntax check, not a spelling check. The typo detection lives in suggestedCorrection, described as "The likely intended email when a common domain typo is detected (e.g. gmial.com to gmail.com), otherwise null". A model that skimmed field names would tell the user the address has no typo. A model that read the descriptions would offer jane@gmail.com.
isMxValid and canReceiveMail answer different questions. The first is about MX records specifically. The second's description spells out that it follows the RFC 5321 fallback to an A record when there is no MX, and adds: "Unlike isMxValid, this follows the RFC fallback, so a parked domain with only a website address can be true." That sentence exists to stop exactly the wrong inference a model would otherwise make.
This is why a precise description is worth more than a clever name. Names are compressed and get misread; descriptions can state the edge case, the unit, the format and the thing it is easy to confuse the field with.
What a good description contains
When you write tool definitions for your own MCP server, or wrap APIVerve endpoints in one, aim for descriptions that answer the questions a model would otherwise guess at:
| Include | Example |
|---|---|
| What it does, in the user's vocabulary | "Spells out a number in English words, for cheques and legal documents" |
| When not to use it | "Not for currency formatting; use a currency tool for symbols and decimals" |
| Units and formats | "Dates are ISO 8601 (YYYY-MM-DD)"; "distance in kilometres" |
| Constraints | "1 to 3999 only"; "max 20 characters" |
| Cost or side effects | "(Cost: 20 credits)"; "Sends an email. Not reversible." |
| The confusable neighbour | "Unlike isMxValid, this follows the RFC fallback" |
Compare two definitions for the same parameter:
{ "date": { "type": "string", "description": "The date" } }
{
"date": {
"type": "string",
"description": "Calendar date in ISO 8601 format (YYYY-MM-DD), interpreted in UTC. Must not be in the future.",
"examples": ["2026-03-14"]
}
}
The vague version gets you "March 14", "14/03/2026" and "yesterday" on different runs. The precise one gets you 2026-03-14.
Bad arguments cost money
A tool that rejects bad input returns a result with isError: true and a message. The model reads it and tries again, often by guessing differently. That is useful when the message is specific and wasteful when it is not, because each retry is another call. Over the REST API, a parameter the endpoint does not declare is ignored rather than rejected: the call succeeds and quietly drops your input. An agent that invents a parameter name gets a confident answer to a slightly different question. The input schema is the defence on both counts, which is why its descriptions deserve the same care as user-facing documentation.
An agent tells a user that jane@gmial.com "has no typo" because hasTypo is false. What went wrong?
03structuredContent and the text block
A tool result can carry its payload twice. The content array holds blocks the model reads; for a JSON-producing tool that is usually one text block containing serialized JSON. On protocol 2025-06-18 and newer, a tool that declares an outputSchema also returns structuredContent: the same data as an object that conforms to that schema. APIVerve puts the identical {status, error, data} envelope in both, so the two cannot disagree.
The practical rule is short: code reads structuredContent; models read whatever the client gives them. If you are writing a host, a test harness or a workflow step that consumes tool results, use the structured half and validate it against the schema you were given at tools/list time. Parsing the text block is a fallback for servers or protocol versions that do not offer structured output.
// Node 18+, ESM. npm install ajv@8
import Ajv from 'ajv';
const ajv = new Ajv({ strict: false });
// tool: the entry from tools/list. result: the tools/call result.
export function readResult(tool, result) {
if (result.isError) {
throw new Error(result.content?.[0]?.text ?? 'tool call failed');
}
let payload = result.structuredContent;
if (payload === undefined) {
// Older protocol version: fall back to the text block.
payload = JSON.parse(result.content.find((c) => c.type === 'text').text);
}
if (tool.outputSchema) {
const validate = ajv.compile(tool.outputSchema);
if (!validate(payload)) {
// Log, don't throw: a schema that drifted from the data should not take the agent down.
console.warn(`outputSchema mismatch: ${ajv.errorsText(validate.errors)}`);
}
}
return payload.data;
}# Python 3.9+. pip install jsonschema
import json
import logging
from jsonschema import Draft202012Validator
def read_result(tool: dict, result: dict) -> dict:
"""tool: the entry from tools/list. result: the tools/call result."""
if result.get("isError"):
raise RuntimeError(result.get("content", [{}])[0].get("text", "tool call failed"))
payload = result.get("structuredContent")
if payload is None:
# Older protocol version: fall back to the text block.
text = next(c["text"] for c in result["content"] if c["type"] == "text")
payload = json.loads(text)
if "outputSchema" in tool:
# Log, don't raise: a schema that drifted from the data should not take the agent down.
for err in Draft202012Validator(tool["outputSchema"]).iter_errors(payload):
logging.warning("outputSchema mismatch at %s: %s", list(err.path), err.message)
return payload["data"]Validation is cheap insurance: if a server changes shape, you find out at the boundary with a clear message instead of three steps later with undefined is not a function. Notice that both versions log a mismatch rather than failing the call. Schemas are written by people and drift from the data. A live example: Number to Words declares afterDecimal as "type": "object", yet its own description says it is null for a whole number, and that is what the API returns for 1204. A strict validator rejects a perfectly good answer. Log mismatches, alert on them, and reserve hard failures for the fields your code actually depends on.
04What null means, and what it does not
The APIVerve server tells every client, in the instructions string it sends on initialize, how to read its results: "null fields mean 'not available for this input or plan'." That one sentence covers two different situations, and both appear in a single call:
curl "https://api.apiverve.com/v1/numbertowords?number=1204" \
-H "x-api-key: YOUR_API_KEY"afterDecimal is null because 1204 has no fractional part. On lower plans, ordinal can be null too.
Null because of the input. afterDecimal is null because 1204 is a whole number. Its description says so in as many words: "or null when the number has no fractional part." suggestedCorrection on Email Validator is null whenever no domain typo was detected. canReceiveMail is null when the DNS lookup could not complete. None of these are errors.
Null because of the plan. Some fields are premium. In the output schema their descriptions end in (premium): on Number to Words that includes ordinal, numberOfDigits_words and eachNumber; on Email Validator it is suggestedCorrection, riskScore and riskLevel. On a plan that does not include them, they come back as null. Code has to work either way.
What null never means is zero, false or empty. This is where agents and the code around them go wrong:
riskScore: nullis not a risk score of 0. Treating it as 0 rates every address as perfectly safe on a plan without the field.canReceiveMail: nullis notfalse. It means the check did not complete, which calls for a retry or a neutral verdict, not a rejection.suggestedCorrection: nullmight mean "no typo" or "your plan does not include this", and only the schema's(premium)marker plus your plan tell you which.
The defensive pattern is to treat every field as three-valued and decide explicitly what unknown should do:
// Node 18+, ESM. data: the result's data object.
export function emailVerdict(data) {
if (data.isDisposable === true || data.isRegexValid === false) return 'block';
if (data.canReceiveMail === false) return 'block';
if (data.canReceiveMail === null) return 'review'; // lookup did not complete
if (typeof data.riskScore === 'number' && data.riskScore >= 70) return 'review';
// riskScore null: plan without the field, or not computed. Do not treat as 0.
return 'allow';
}For an agent rather than code, put the rule in words where the model will see it, in your system prompt or your own server's instructions: a null field means the information is not available, it must not be reported as a negative, and a premium null should be described as "not included" rather than "none found".
Your code reads riskScore from Email Validator and uses score ?? 0 before comparing it to a threshold. Why is that a bug?
05Next
Schemas tell an agent what a tool does. They say nothing about whether it should be allowed to, how much it may spend, or whether the text a tool returns can be trusted. That is the last lesson of this course: Keys, scopes and safety for agents.