Ask a language model to write 1987 in Roman numerals and it will usually get it right. Ask it to spell out 975.07 as words for a cheque, convert a timestamp in a timezone it has never heard of, or tell you whether a domain has mail servers right now, and "usually" stops being good enough. The model is predicting text. Some questions need a computation or a lookup, and the honest move is to hand them to something that does that job deterministically.
That handoff is a tool call, and the Model Context Protocol (MCP) is the standard way to wire one up. It is an open protocol, published at modelcontextprotocol.io, that defines how an AI application discovers what a service can do and asks it to do it. This lesson takes it apart: the three kinds of thing a server can offer, who the parties are, what goes over the wire, and how the model ends up choosing a tool at all.
01Tools, resources and prompts
An MCP server can expose three kinds of capability. The difference between them is not the data format. It is who decides when they are used.
| Primitive | Controlled by | What it is | Typical example |
|---|---|---|---|
| Tools | The model | A function with a name, a description and a JSON Schema for its arguments. The model decides to call it. | Convert a number to words, validate an email, query a database |
| Resources | The application | Read-only context identified by a URI, which the host app chooses to attach. | A file, a database schema, a document |
| Prompts | The user | A reusable template the user picks, often surfaced as a slash command. | "Summarise this pull request" |
Most of the attention goes to tools, because tools are where an agent gains the ability to act on the world rather than just talk about it. Resources are the right shape for context that the app already knows it wants loaded. Prompts are a way for a server author to ship a well-tuned instruction alongside the capability it drives.
The APIVerve MCP server implements tools only. Its documentation is explicit: it answers initialize, tools/list and tools/call, and there are no resources or prompts, because the catalog is a set of callable endpoints and there is nothing static to mount. That is a perfectly normal server profile. Many production servers expose only tools.
A server author wants the host application, not the model, to decide when a project's database schema is loaded into context. Which primitive fits?
02Host, client and server
Three roles show up in every MCP setup, and mixing them up is the source of most confused bug reports.
- The host is the application you interact with: an editor such as Cursor or VS Code, a desktop chat app, a command-line coding agent, or your own program. It owns the model conversation and the user interface.
- The client lives inside the host. The host creates one client per server it connects to, and each client holds exactly one connection.
- The server exposes capabilities. It can be a local process the host launches, or a remote service reached over HTTPS.
Messages between client and server are JSON-RPC 2.0: every request has a method, params and an id, and every response carries a result or an error with the same id. The protocol defines two standard transports. stdio runs the server as a child process and exchanges messages over standard input and output; it is simple and local. Streamable HTTP sends each message as an HTTP POST to a single endpoint and is how remote, hosted servers work. APIVerve's server is a hosted Streamable HTTP endpoint at https://api.apiverve.com/v1/mcp, with a Docker image for clients that can only speak stdio. Lesson 2 covers both.
A session follows a fixed order. The client sends initialize with the protocol version it speaks and its capabilities; the server replies with the version it agrees to, its own capabilities and, optionally, an instructions string meant for the model. The client then asks tools/list to learn what exists, and from then on sends tools/call whenever the model wants something done.
03What goes over the wire
The fastest way to understand MCP is to skip the client and send the messages yourself. Appending an endpoint id to the APIVerve server URL gives you a server that exposes exactly one tool, which keeps the output readable.
curl -s -X POST https://api.apiverve.com/v1/mcp/numbertowords \
-H "x-api-key: $APIVERVE_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'The response describes one tool. Trimmed to the parts that matter:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"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 ... (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": {}, "error": {}, "data": {} } },
"annotations": { "readOnlyHint": true, "openWorldHint": true }
}
]
}
}
Everything the model will ever know about this tool is in that object: a name it can emit, a description it reads to decide relevance, and an inputSchema that tells it the argument is a number called number and that it is required. The credit cost sits at the end of the description, so the model can see what a call costs before it makes one.
Now the call itself. The params carry the tool name and an arguments object that must satisfy the input schema:
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"NumbertoWords","arguments":{"number":975.07}}}
The data that comes back is the same as calling the REST endpoint directly. Run it here:
curl "https://api.apiverve.com/v1/numbertowords?number=975.07" \
-H "x-api-key: YOUR_API_KEY"words is the answer a model would quote. afterDecimal holds the fractional part.
Over MCP that data arrives inside a tool result. A result has a content array, which is what older clients show to the model as text, and on protocol version 2025-06-18 and newer a structuredContent object that matches the tool's outputSchema. APIVerve puts the same {status, error, data} envelope in both, so they cannot disagree. A failure is not a JSON-RPC error: it is a normal result with isError: true and a sentence explaining what went wrong, written for the model to read. Send a key the server does not recognise and the call comes back like this:
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Authentication failed for NumbertoWords: unauthorized. The API key may be invalid, expired, or revoked. ..."}],"isError":true}}
That design choice matters. A protocol error would surface in the client as an opaque failure. A tool result with isError lands in front of the model, which can fix its arguments or tell the user what happened.
The same exchange from code
A client library hides all of this, but writing the raw version once makes debugging much easier later. This one initializes, then calls the tool. It sends back an Mcp-Session-Id header if the server hands one out, which the Streamable HTTP transport allows a server to do.
// Node 18+, ESM. Run: node mcp-call.mjs
const URL_ = 'https://api.apiverve.com/v1/mcp/numbertowords';
const key = process.env.APIVERVE_API_KEY;
if (!key) throw new Error('APIVERVE_API_KEY is not set');
let session = null;
async function rpc(id, method, params) {
const headers = {
'x-api-key': key,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
};
if (session) headers['Mcp-Session-Id'] = session;
const res = await fetch(URL_, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
signal: AbortSignal.timeout(65_000),
});
session = res.headers.get('mcp-session-id') ?? session;
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const msg = await res.json();
if (msg.error) throw new Error(`JSON-RPC ${msg.error.code}: ${msg.error.message}`);
return msg.result;
}
const init = await rpc(1, 'initialize', {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'academy-demo', version: '1.0.0' },
});
console.log('server speaks', init.protocolVersion);
const result = await rpc(2, 'tools/call', { name: 'NumbertoWords', arguments: { number: 975.07 } });
if (result.isError) throw new Error(result.content[0].text);
console.log(result.structuredContent.data.words);# Python 3.9+, requests. Run: python mcp_call.py
import os
import requests
URL = "https://api.apiverve.com/v1/mcp/numbertowords"
KEY = os.environ["APIVERVE_API_KEY"]
session_id = None
def rpc(rpc_id, method, params):
global session_id
headers = {
"x-api-key": KEY,
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if session_id:
headers["Mcp-Session-Id"] = session_id
res = requests.post(
URL,
headers=headers,
json={"jsonrpc": "2.0", "id": rpc_id, "method": method, "params": params},
timeout=65,
)
session_id = res.headers.get("mcp-session-id", session_id)
res.raise_for_status()
msg = res.json()
if "error" in msg:
raise RuntimeError(f"JSON-RPC {msg['error']['code']}: {msg['error']['message']}")
return msg["result"]
init = rpc(1, "initialize", {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "academy-demo", "version": "1.0.0"},
})
print("server speaks", init["protocolVersion"])
result = rpc(2, "tools/call", {"name": "NumbertoWords", "arguments": {"number": 975.07}})
if result.get("isError"):
raise RuntimeError(result["content"][0]["text"])
print(result["structuredContent"]["data"]["words"])Two details in that code are deliberate. The timeout is a little over 60 seconds because the server documents a 60-second limit per call, so you want its error rather than your own. And the code reads structuredContent rather than parsing the text block, because the structured half is the one with a schema behind it. A Streamable HTTP server is also allowed to answer with an event stream instead of a single JSON body; this server answers these requests with JSON, but a general-purpose client has to handle both, which is one good reason to use an SDK in production.
A tool call fails because the model passed a malformed argument. How does a well-behaved MCP server report it?
04How the model decides to call a tool
The model never calls anything. It is worth being precise about the loop, because every agent bug you will chase lives somewhere in it.
- The host takes the tool list it received and puts each tool's name, description and input schema into the model's context, in whatever format that model's API expects for function calling.
- The user asks something. The model, reading the conversation and the tool descriptions, can respond with text or with a structured request to use a tool, including arguments it generated to fit the schema.
- The host decides whether to honour that request. It may run it at once, or ask the user to approve it first.
- The client sends
tools/call, the server does the work, and the result goes back into the model's context. - The model reads the result and either answers or asks for another tool.
So the model's choice is driven entirely by text it was given: the tool's name and description, the parameter descriptions, and anything the server said in its instructions. A vague description gets a tool called at the wrong time or not at all. A precise one gets it called exactly when it should be. Lesson 3 is about writing and reading those descriptions.
Here is the kind of question that should trigger a tool. Roman numeral conversion has one right answer, and a lookup is cheaper than a mistake on a legal document or a building plaque:
curl "https://api.apiverve.com/v1/romanconverter?number=1987" \
-H "x-api-key: YOUR_API_KEY"roman is MCMLXXXVII, and breakdown shows how it was built.
The tool also changes how much you can trust the answer. A model that says "MCMLXXXVII" from memory gives you a claim. A model that called a converter gives you a claim plus a record of the call in the transcript, and that record is what you check when something looks off.
05Why a protocol instead of bespoke integrations
Before MCP, connecting a model to a service meant writing glue for each pair. Every model provider had its own function-calling format, every host app had its own plugin system, and a service that wanted to be usable from five assistants maintained five integrations. Every new host multiplied the work again.
A protocol turns that multiplication into addition. A service writes one server; every host that speaks MCP can use it. A host implements one client; every MCP server works in it. The tool definition in JSON Schema is the shared language, and each host translates it into its own model's function-calling format internally.
There is a second benefit that matters more over time: discovery happens at connection time, not at build time. APIVerve generates its tool list from the same catalog its REST API runs on, so a new endpoint is a new tool the day it ships, without anyone editing a config. A bespoke integration would have hard-coded the list the day someone wrote it.
The protocol is not free of cost, and an experienced engineer should name the tradeoffs:
- Context is a budget. Every tool definition occupies tokens in the model's context, and a long list makes the choice harder. Exposing hundreds of tools to an agent with one job is usually a mistake, which is why the single-tool endpoint you used above exists.
- Trust is now a design question. A server's descriptions and results flow straight into the model. A server you do not control can say anything, and lesson 4 covers what that means for safety.
- The standard moves. Protocol versions are dated (2024-11-05, 2025-03-26 and 2025-06-18 are all accepted by the APIVerve server, which echoes back the version your client asked for). Newer features such as structured output only appear when both sides support them, so you check the negotiated version rather than assume.
None of those are reasons to write bespoke glue instead. They are the things you design around.
Your agent answered a currency question confidently, but the transcript shows no tool call. What is the most likely explanation?
06Next
You have seen a tool listed, called and failed by hand. Next you will connect the APIVerve server to a real client, prove the connection works with a call that costs one credit, and learn the handful of mistakes that account for nearly every "the tools don't show up" report: Connect the APIVerve MCP server.