What Is MCP?

Table of Contents

  1. The N-Times-M Problem
  2. Host, Client and Server
  3. What a Server Offers
  4. Transports: stdio and HTTP
  5. JSON-RPC 2.0 in Five Minutes
  6. The Lifecycle of a Session
  7. Drive a Server by Hand
  8. Replies Can Arrive Out of Order
  9. Tool Results: Two Kinds of Failure
  10. From MCP Tools to the Model's tools Field
  11. MCP vs Function Calling vs REST
  12. The Ecosystem, Honestly
  13. Recipe: The by-hand Scripts
  14. Try It Yourself
  15. Quick Review: Common Questions

1. The N-Times-M Problem

In the last chapters you wrote tools as JavaScript functions inside your own agent. That works, until you want the same tools in a second program. Imagine you wrote a "read my notes" tool for your agent. Now you want it in a coding assistant, in a chat app and in a script. Each program has its own way of describing tools, so you write the glue three times.

Now scale up. If there are N applications that use models, and M services you want to connect (files, databases, calendars, issue trackers), then without a standard every pair needs its own custom glue: N x M pieces of work. With a standard, each application learns the standard once and each service speaks it once: N + M pieces.

This is the same idea as a USB-C port. Before it, every phone and every gadget had its own charger shape. With one port, any charger fits any phone.

The Model Context Protocol (MCP) is an open standard for exactly this: a common way for an AI application to discover and call tools (and some other things) offered by separate programs. The official home is modelcontextprotocol.io, and it holds the current specification.

One thing to say clearly now, so you do not get disappointed later: MCP is plumbing. It standardises how tools are described, found and called. It does not make the model smarter, and it does not make a tool safe. Both of those stay your job.

2. Host, Client and Server

MCP names three roles:

  • The host is the application the person uses: a coding assistant, a chat app, or the run-agent.js script you will write in Chapter 8. It contains the model and the agent loop.
  • The client is a piece of code inside the host that holds one connection to one server. A host that connects to three servers has three clients.
  • The server is a separate program that offers capabilities: it lists tools and runs them when asked.
  +------------------ host (your agent app) -------------------+
  |   model + agent loop                                       |
  |       |                                                    |
  |   client A ----------- client B ----------- client C       |
  +-------|------------------|-----------------|---------------+
          |                  |                 |
     server: files     server: database   server: notes
     (subprocess)       (subprocess)       (remote service)

Think of the host as a laptop, each client as a cable, and each server as a device on the other end of the cable. The host does not need to know what a device does inside. It only needs the cable's standard.

In our own code (later chapters), McpClient is the client and createMcpServer is the server. The host is your run-agent.js.

3. What a Server Offers

A server can offer three kinds of things. This series builds tools only; the others are good to know about.

KindWho decides to use itExample
ToolsThe model chooses to call themadd, get_weather, send_email
ResourcesThe application attaches them as contexta file, a database row, a document
PromptsThe user picks them, like a menua "summarise this file" template

Tools are actions. The model reads their descriptions and decides when to call them, exactly as in Tool Calling Basics. Resources are data the host can read and show to the model. Prompts are reusable message templates. The spec defines details for all three, and more features exist beyond them, so use the current specification for anything you build beyond tools.

4. Transports: stdio and HTTP

A transport is how the messages travel between client and server. The messages themselves are the same either way. Two transports matter at the concept level:

stdio. The host starts the server as a child process on the same machine and talks to it through its standard input and output, one message per line. No network, no ports, no login. This is how most local servers run. The server inherits the permissions of the user who started it, which matters for safety (Chapter 9).

HTTP. For a server that runs somewhere else (a company service, a cloud), the client sends messages over HTTP. This is where networking, authentication and multiple users come in. The spec calls its current HTTP transport "Streamable HTTP", and earlier versions of the spec described a different HTTP style, so check the current specification before building one.

We use stdio in this series. It is free, local, and it lets you see every message in a terminal.

5. JSON-RPC 2.0 in Five Minutes

MCP messages are JSON-RPC 2.0, a simple, older standard for "call a named method with parameters and get a result". There are three message types, and only these three.

A request has an id, a method and (usually) params:

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}

A response has the same id and either a result or an error, never both:

{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"5"}],"isError":false}}
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found: resources/list"}}

A notification has a method but no id, and the receiver must not reply. It means "FYI":

{"jsonrpc":"2.0","method":"notifications/initialized"}

The id is how a client matches a reply to its question. That is all it is for, and section 8 shows why it matters. The standard error codes you will meet: -32700 (the text was not valid JSON), -32601 (no such method) and -32602 (invalid parameters).

On stdio, each message is one line of JSON ending in a newline. That is why a server must never print anything else to stdout. Chapter 7 shows what breaks when it does.

6. The Lifecycle of a Session

Every MCP session follows the same steps:

  1. initialize (request). The client says which protocol version it speaks, which capabilities it has and who it is. The server replies with its own version, capabilities and name.
  2. notifications/initialized (notification). The client says "I am ready". No reply.
  3. tools/list (request). The client asks what tools exist and gets names, descriptions and input schemas.
  4. tools/call (request), as many times as needed. The client asks the server to run a tool.
  5. Close. On stdio, the client ends the child process.

Protocol versions are dated strings such as "2024-11-05" (the one our code uses as an example). The specification gets new dated versions over time, so check the current spec for the latest and for what each version changed. In the handshake, the two sides agree on a version they both support. Our learning server simply echoes back whatever the client asked for, which is a simplification; the official SDKs do real negotiation.

7. Drive a Server by Hand

Time to see real messages. The server is src/demo-server.js (Chapter 7 explains how it works; for now treat it as a black box). It exposes the five demo tools you know: get_weather, add, save_note, read_note and list_notes.

You can talk to it without any special software, because stdio is just text. First, one line in, one line out. In your agentlab folder:

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"by-hand","version":"0.0.1"}}}' | node src/demo-server.js 2>/dev/null
# output
# {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"demo-server","version":"1.0.0"}}}

The server answered with its name and a capabilities object that says tools are supported (and nothing else). The 2>/dev/null hides stderr, where the server prints "MCP server ready (stdio)". The server writes its logs to stderr so that stdout carries only protocol messages.

Now a whole session from a file. Each line is one message:

// session.jsonl
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"by-hand","version":"0.0.1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"Kampot"}}}
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"no_such_tool","arguments":{}}}
{"jsonrpc":"2.0","id":6,"method":"resources/list"}
this is not json

Line 2 is a notification. Line 5 asks for a city the tool does not know, line 6 for a tool that does not exist, line 7 for a method this server does not implement, and line 8 is not even JSON. Feed it all in and print one short summary line per reply with a helper script:

// summarize.js
import readline from "node:readline";
// Print one short line per reply: the id, then what kind of reply it is.
for await (const line of readline.createInterface({ input: process.stdin })) {
  const m = JSON.parse(line);
  const what = m.error ? `error ${m.error.code} "${m.error.message}"` : `result with keys: ${Object.keys(m.result).join(", ")}`;
  console.log(`id ${m.id}: ${what}`);
}
node src/demo-server.js < session.jsonl 2>/dev/null | node summarize.js
# output
# id null: error -32700 "Parse error"
# id 1: result with keys: protocolVersion, capabilities, serverInfo
# id 2: result with keys: tools
# id 5: error -32602 "Unknown tool: no_such_tool"
# id 6: error -32601 "Method not found: resources/list"
# id 4: result with keys: content, isError
# id 3: result with keys: content, isError

Read it against the file:

  • There is no reply for line 2, the notification.
  • tools/list (id 2) returned a tools array. Section 10 shows what is inside it.
  • The bad line got a -32700 error with id: null, because the server could not read an id from broken JSON.
  • The unknown tool got -32602, and the unknown method got -32601.
  • The tool calls for ids 3 and 4 came back last and in the opposite order. That is not a bug.

8. Replies Can Arrive Out of Order

Why did id 4 arrive before id 3? The server handles each line as soon as it arrives, and running a tool is asynchronous, so a quick reply can overtake a slow one. The order of replies on the wire is not promised. The id is the contract. This is why a client must keep a table of "requests waiting for an answer" and match replies by id, not by position. Chapter 8's McpClient does exactly that.

Your exact order may differ from the output above, since it depends on timing. The set of replies and their ids will not.

9. Tool Results: Two Kinds of Failure

Look at what a successful tool call returns. This is the result of tools/call:

{"content":[{"type":"text","text":"5"}],"isError":false}
  • content is an array of content items. The simple case is {"type":"text","text":"..."}. The spec describes other content types too (such as images); check it when you need them.
  • isError says whether the tool itself failed.

There are two very different failures, and mixing them up is a common mistake:

Protocol errorTool error
ShapeJSON-RPC error objectnormal result with isError: true
MeaningThe request was wrong: unknown method, unknown tool, invalid paramsThe request was fine, the tool failed: city not found, file missing
Who should see itYour code (a bug or a version mismatch)The model, so it can retry or explain

A tool error is part of the conversation. In our server, the call for "Kampot" came back as a normal result whose text begins with Error: and whose isError is true. The agent loop passes that text to the model as a tool message, just like in Tool Calling Basics, so the model can try again with a known city. A protocol error, on the other hand, normally means a bug in your host or server, and the model cannot fix it.

10. From MCP Tools to the Model's tools Field

Here is the key point for an agent builder: the model does not speak MCP. The model API only knows its own tools field. The host is the translator. It asks the MCP server tools/list, converts each tool to the chat API shape, and sends those. When the model answers with a tool_calls entry, the host turns it into an MCP tools/call, and puts the result back as a tool message.

The two descriptions are nearly the same. MCP calls the schema inputSchema, and the chat API calls it parameters. The script below does the whole handshake from code, prints one tool in both shapes, and makes the three kinds of call you saw above:

// by-hand.js
import { spawn } from "node:child_process";
import readline from "node:readline";

// Start the server as a child process and talk to it over stdin/stdout.
const server = spawn(process.execPath, ["src/demo-server.js"], { stdio: ["pipe", "pipe", "inherit"] });
const lines = readline.createInterface({ input: server.stdout })[Symbol.asyncIterator]();

let nextId = 1;
async function request(method, params) {
  const id = nextId++;
  server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
  const { value } = await lines.next(); // fine here: we send one request at a time
  return JSON.parse(value);
}
const notify = (method, params) => server.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");

const init = await request("initialize", {
  protocolVersion: "2024-11-05",
  capabilities: {},
  clientInfo: { name: "by-hand", version: "0.0.1" },
});
console.log("server:", init.result.serverInfo, "| capabilities:", init.result.capabilities);
notify("notifications/initialized");

const list = await request("tools/list");
// Map MCP tools onto the chat API's `tools` field: inputSchema becomes parameters.
const apiTools = list.result.tools.map((t) => ({
  type: "function",
  function: { name: t.name, description: t.description, parameters: t.inputSchema },
}));
console.log("MCP tool:", JSON.stringify(list.result.tools[1]));
console.log("API tool:", JSON.stringify(apiTools[1]));

console.log(JSON.stringify((await request("tools/call", { name: "add", arguments: { a: 2, b: 3 } })).result));
console.log(JSON.stringify((await request("tools/call", { name: "get_weather", arguments: { city: "Kampot" } })).result));
console.log(JSON.stringify((await request("tools/call", { name: "nope", arguments: {} })).error));
server.stdin.end();
// output
// server: { name: 'demo-server', version: '1.0.0' } | capabilities: { tools: {} }
// MCP tool: {"name":"add","description":"Add two numbers","inputSchema":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}}
// API tool: {"type":"function","function":{"name":"add","description":"Add two numbers","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}}}
// {"content":[{"type":"text","text":"5"}],"isError":false}
// {"content":[{"type":"text","text":"Error: no weather data for \"Kampot\". Known cities: Phnom Penh, Siem Reap, Takeo"}],"isError":true}
// {"code":-32602,"message":"Unknown tool: nope"}

The very first line of output you may see in your terminal is MCP server ready (stdio) from the server's stderr, which this script passes through to your terminal. The rest is stable.

Now you can see the mapping: inputSchema became parameters, name and description stayed as they were. Everything the model ever learns about an MCP tool comes from that name, description and schema. So the advice from Designing Good Tools applies unchanged: a vague description in an MCP server is just as bad as a vague description in your own code.

11. MCP vs Function Calling vs REST

These three get mixed up, so here is how they relate:

  • Function calling (tool calling) is a feature of the model API: the model can ask for a function call using the tools field. It is about the conversation between your program and the model.
  • MCP is a protocol between your program and a tool server. It is about how tools are described, discovered and run. MCP tools end up in the model's function-calling field. They work together; they do not compete.
  • A REST API is a service interface designed for programmers. An MCP server may well call a REST API inside. MCP does not replace REST; it wraps services in a form an agent host can discover and use without custom code.

What does MCP give you over writing your own tool functions?

  • Reuse. Write the server once; use it from any host that speaks MCP.
  • Discovery. The host asks the server what it can do. New tools appear without changing the host.
  • Separation. The tool runs in its own process, perhaps in a different language, perhaps in a sandbox.

What does it cost?

  • Another moving part: processes to start, versions to match, messages to debug.
  • A trust problem. A server is code that runs with your permissions. A tool description is text that goes into the model's prompt. Both can be abused (Chapter 9).
  • No intelligence for free. If your own agent uses three tools in one program, plain functions are simpler. Use MCP when you want reuse, separation, or tools others already built.

12. The Ecosystem, Honestly

Since MCP is a public standard, many servers exist: for files, databases, developer tools, search, documents, and company products. Many hosts support it. That is useful, and also needs caution:

  • Quality varies. Some servers are well-maintained, others are weekend projects. A server may have bugs, or return huge outputs.
  • Trust varies. Installing a server often means running someone else's code on your machine. Treat it like installing any other program: read about the author, pin a version, and give it only what it needs. Chapter 9 goes deeper.
  • Things change. The specification gets new dated versions, and features come and go. This series teaches the stable core (JSON-RPC, the handshake, tools). For anything else, read the current spec at modelcontextprotocol.io.

A good habit: before you trust a server, run tools/list and read the names and descriptions yourself. That is exactly what the model will read.

13. Recipe: The by-hand Scripts

This chapter has no new module for agentlab. It leaves you three small files for exploring any stdio MCP server. They are all shown above; here is the checklist so you can recreate them:

  • session.jsonl: the eight lines of the session in section 7. Edit it and rerun to experiment.
  • summarize.js: prints one line per reply.
  • by-hand.js: starts the server as a child process, waits for each reply, and shows the MCP tool and the chat API tool side by side.

Notice the one trick in by-hand.js: request sends a message and then reads the next line. That is only correct because it never has two requests in flight. Section 8 explains why a real client cannot assume that, and Chapter 8 builds a client that matches replies by id.

To point by-hand.js at another server (for example your own from Chapter 7) change the line that calls spawn. Keep the rule: read the descriptions before you let a model see them.

14. Try It Yourself

  1. Add a line to session.jsonl that calls save_note with a title and content, then another that calls read_note. Does the note survive within one session? Run the file twice. Does it survive between runs?
  2. Remove the notifications/initialized line. Does this server still answer tools/list? What does that tell you about how strict the server is, and why a real client should still send it?
  3. Send {"jsonrpc":"2.0","id":9,"method":"ping"}. What comes back?
  4. Call add with {"a":"two","b":3}. Is the failure a protocol error or a tool error? Check the isError flag.
  5. Change by-hand.js to print the names and descriptions of all five tools, then decide which description you would improve and why.
  6. Write down the three messages (as JSON) a client would send to ask a server named notes for its tools, starting from the very first byte. Compare your answer with section 6.

15. Quick Review: Common Questions

Questions beginners often ask about this chapter. Try answering each one yourself first, then click the question to open the answer.

What is MCP in one sentence?

It is an open standard that lets an AI application find and call tools that live in separate programs, using JSON messages over a simple channel. One server can then work with many applications.

Is MCP just an API?

It uses ordinary pieces (JSON-RPC messages) but it is not one service you call. It is an agreement about how a host and a tool server talk: the handshake, how tools are listed and how they are called. A normal REST API has no such shared agreement, so each one needs custom code.

Does MCP make the model better at using tools?

No. The model sees names, descriptions and schemas, same as with plain function calling. MCP only standardises how those arrive. If a description is vague, the model will still guess badly.

Does MCP make tools safe?

No. MCP does not decide what a tool is allowed to do. A local server runs with your permissions, and a tool description can carry misleading text. Safety is your job: permissions, approval and sandboxing, which Chapter 9 covers.

Should every tool be an MCP server?

No. For tools used by one program only, a plain function is simpler and faster. Choose MCP when you want reuse across applications, a separate process, or tools that other people already publish.

Why do local servers use stdin and stdout?

Because every operating system and language supports them, and they need no port, no network and no login. The host starts the process and has a private channel to it. The price is that stdout must carry only protocol messages, so logs go to stderr.

Which protocol version should I use?

The newest one both your client and server support. Versions are dated strings and new ones appear over time, so check the current specification instead of copying a version from a tutorial, including this one.

Where to go next. You have seen the messages from the outside. Next you will build the other side: a working MCP server in plain Node.js, line by line, and see what breaks when it prints to the wrong place. Build an MCP Server

Buy Me a Coffee

Found this tutorial helpful? Consider buying me a coffee to support more free content!