Build an MCP Server
Table of Contents
- What We Are Building
- Messages Are Lines
- The Server, Step by Step
- Drive It by Hand
- Protocol Errors and Tool Errors
- Notifications Get No Reply
- Never Print Logs to stdout
- Add Your Own Tool: word_count
- Test It with Code
- The Config Shape Hosts Use
- Real Projects Use the Official SDK
- What We Skipped
- Your Server Runs With Your Permissions
- Recipe: The MCP Server Modules
- Try It Yourself
- Quick Review: Common Questions
1. What We Are Building
In What Is MCP? you drove a server by hand and treated it as a black box. Now we open the box. An MCP server is a program that:
- reads JSON-RPC messages from its standard input,
- answers
initialize,tools/listandtools/call, - writes the replies to its standard output.
That is all. If you can read lines and write lines, you can write an MCP
server. Our version lives in one file, src/mcp-server.js, and takes
the tools you already know from
Tool Calling Basics
(defineTool objects) and exposes them over MCP. The same tool code
works as a plain function-calling tool and as an MCP tool. Only the
wiring changes.
Think of a restaurant kitchen with a service window. The tools are the cooks. The server is the person at the window: it takes an order slip (a request), passes it to the right cook, and hands back a plate (a reply) with the same ticket number on it.
Honest note. This is a learning subset, not a full MCP implementation. It handles only tools, only stdio, and only the messages needed to make an agent work. Section 11 explains what real projects use instead, and section 12 lists what we skipped.
2. Messages Are Lines
Over stdio, each message is one JSON object on one line, ending in a
newline. No headers, no length prefix. That is why a message must never
contain a raw newline: JSON.stringify never produces one (newlines
inside strings become the two characters \n).
Three kinds of message, as you saw in chapter 6:
- Request: has an
idand amethod. It expects a reply with the sameid. - Response: has the
idof the request, and either aresultor anerror. - Notification: has a
methodbut noid. Nobody replies.
The id is what lets a client send three requests without waiting and
still know which reply belongs to which. Replies can arrive in a
different order from the requests (you will see this in section 5), so
a client must match by id, never by position.
3. The Server, Step by Step
The file has two exports. createMcpServer(tools, info) builds the
protocol logic and returns an object with one method, handle(msg).
serveStdio(server) connects that logic to stdin and stdout. Keeping
them apart means you can test the logic without starting any process
(section 9).
handle returns a reply or null. First line of the function:
if (id === undefined) return null; // a notification
No id means a notification, so there is nothing to send back.
initialize is the handshake. The client says which protocol
version it speaks and what it supports; the server answers with the
version it will use, its own capabilities, and serverInfo. Ours
echoes the client's protocolVersion back, and declares one capability:
tools: {}, which means "I have tools". A full server would compare
versions and refuse ones it does not know. Check the current
specification for the exact rules.
tools/list maps each of our tools to the MCP shape. One rename:
our parameters becomes inputSchema. The schema inside is the same
JSON Schema.
tools/call finds the tool by params.name, then reuses
runToolCall from chapter 2. That function already validates the
arguments against the schema, runs the tool, truncates long output, and
turns every failure into an Error: ... string. The server only wraps
the result in MCP's shape: a content array with one text item, plus
isError.
Anything else gets error -32601, "Method not found".
serveStdio uses Node's readline to get one line at a time, tries
JSON.parse, and answers a bad line with error -32700, "Parse
error". Then it calls handle and writes the reply as one line.
4. Drive It by Hand
You do not need a client to test a server. Pipe lines into it. Our
second server, src/text-server.js (section 8), has two small tools.
This sends the handshake, asks for the tool list, and calls one tool:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"hand","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"word_count","arguments":{"text":"sok sabay chea mean"}}}' \
| node src/text-server.js
# output
# MCP server ready (stdio)
# {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"text-server","version":"0.1.0"}}}
# {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"word_count","description":"Count the words in a piece of text","inputSchema":{"type":"object","properties":{"text":{"type":"string","description":"The text to count words in"}},"required":["text"]}},{"name":"to_upper","description":"Convert text to upper case","inputSchema":{"type":"object","properties":{"text":{"type":"string","description":"The text to convert"}},"required":["text"]}}]}}
# {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"4 words"}],"isError":false}}
Read the output line by line:
MCP server ready (stdio)is the server's log line. It went to stderr, which your terminal also shows. It is not part of the protocol. Add2>/dev/nullto hide it.- The reply to
id1 is the handshake answer. - There is no reply for the
notifications/initializedline. That is correct (section 6). - The reply to
id2 lists both tools with theirinputSchema. - The reply to
id3 carries"4 words"inside acontentarray.
Everything here is stable. If you copy the commands, you should get the same lines.
5. Protocol Errors and Tool Errors
There are two different ways a call can fail, and they look different on the wire. Keep them apart in your head:
- A protocol error means the request itself was wrong: not JSON,
an unknown method, an unknown tool. The reply has an
errorfield with a numericcode. - A tool error means the request was fine and the tool ran, but
the work failed: bad arguments, a missing file, an exception. The
reply has a normal
resultwithisError: true, and the message is incontent.
The distinction matters because of who reads it. A protocol error is a bug in the client or a mismatch between the two sides: a developer needs to see it. A tool error is information for the model: it can read "missing required argument" and try again, exactly like in The Agent Loop.
Let us trigger each one:
printf '%s\n' \
'this is not json' \
'{"jsonrpc":"2.0","id":1,"method":"nope/nothing"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"missing_tool"}}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"word_count","arguments":{}}}' \
'{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"word_count","arguments":{"text":"a b c"}}}' \
'{"jsonrpc":"2.0","method":"notifications/whatever"}' \
'{"jsonrpc":"2.0","id":5,"method":"ping"}' \
| node src/text-server.js 2>/dev/null
# output
# {"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}
# {"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found: nope/nothing"}}
# {"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"Unknown tool: missing_tool"}}
# {"jsonrpc":"2.0","id":5,"result":{}}
# {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Error: missing required argument \"text\""}],"isError":true}}
# {"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"3 words"}],"isError":false}}
| Input | Reply | Kind |
|---|---|---|
this is not json | -32700 Parse error (id is null, because we could not read an id) | protocol error |
nope/nothing | -32601 Method not found | protocol error |
| unknown tool name | -32602 Unknown tool (code means "invalid params") | protocol error |
| missing argument | normal result, isError: true | tool error |
| valid call | normal result, isError: false | success |
The codes come from JSON-RPC 2.0: -32700 parse error, -32601 method
not found, -32602 invalid params. Our server treats an unknown tool as
"invalid params" because the name parameter is wrong. Other servers
may choose differently, so check the current specification before
depending on exact codes.
Look at the order of the output: the replies for id 5, then 3 and
4. The ping reply came first because tool calls are async and take a
little longer. This is the reason clients match replies by id.
6. Notifications Get No Reply
Two lines in the test above had no id: notifications/initialized
(section 4) and notifications/whatever (section 5). Neither produced
any output, not even an error for the made-up one. That is the rule: a
notification is one-way, and answering it would confuse the client.
A server that replies to a notification breaks the conversation in a
subtle way: the client receives an id it never asked about. Our
handle avoids this by returning null before looking at the method.
7. Never Print Logs to stdout
On stdio, stdout belongs to the protocol. Every byte on it must be a JSON message. If your server prints anything else there, the client tries to parse it as JSON and fails.
This is the most common mistake when writing a first server, because
console.log is what we all type when debugging. Here is a server with
exactly that bug (src/noisy-server.js):
import { createMcpServer, serveStdio } from "./mcp-server.js";
import { defineTool } from "./tools.js";
const tools = [
defineTool({
name: "shout",
description: "Convert text to upper case",
parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
run: ({ text }) => {
console.log("DEBUG: shout called with", text); // BUG: this goes to stdout
return text.toUpperCase();
},
}),
];
serveStdio(createMcpServer(tools, { name: "noisy" }));
Call it and look at what comes out of stdout:
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"shout","arguments":{"text":"hi"}}}' \
| node src/noisy-server.js 2>/dev/null | tee out.txt
# output
# DEBUG: shout called with hi
# {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"HI"}],"isError":false}}
The first line is not JSON. A strict client that runs JSON.parse on
every line fails on it. This small script plays that strict client:
// strict.js
import { readFileSync } from "node:fs";
for (const line of readFileSync("out.txt", "utf8").trim().split("\n")) {
try {
JSON.parse(line);
console.log("ok :", line.slice(0, 50));
} catch (err) {
console.log("FAIL :", line, "->", err.name);
}
}
// output
// FAIL : DEBUG: shout called with hi -> SyntaxError
// ok : {"jsonrpc":"2.0","id":1,"result":{"content":[{"typ
Our own McpClient (chapter 8) is forgiving and quietly skips lines it
cannot parse, so it would survive this. Do not rely on that. Other
clients may drop the connection, show an error, or hang. The fix is one
word: console.error writes to stderr, which clients treat as a
place for logs.
console.error("DEBUG: shout called with", text); // safe
The rule of thumb: in an MCP stdio server, console.log is forbidden.
Also watch libraries you import. If one prints a banner to stdout, it
breaks your server the same way.
8. Add Your Own Tool: word_count
A server is only useful with tools worth calling. Here is a second
server, src/text-server.js, with its own tools. Nothing in it knows
about MCP except the last line:
import { createMcpServer, serveStdio } from "./mcp-server.js";
import { defineTool } from "./tools.js";
const tools = [
defineTool({
name: "word_count",
description: "Count the words in a piece of text",
parameters: {
type: "object",
properties: { text: { type: "string", description: "The text to count words in" } },
required: ["text"],
},
run: ({ text }) => {
const words = text.trim().split(/\s+/).filter(Boolean);
return `${words.length} words`;
},
}),
defineTool({
name: "to_upper",
description: "Convert text to upper case",
parameters: {
type: "object",
properties: { text: { type: "string", description: "The text to convert" } },
required: ["text"],
},
run: ({ text }) => text.toUpperCase(),
}),
];
serveStdio(createMcpServer(tools, { name: "text-server", version: "0.1.0" }));
To add another tool to any server, add another defineTool(...) to the
array. That is the whole job. The server code does not change.
Two details to copy from word_count:
- The
descriptionis written for the model: "Count the words in a piece of text". The model decides whether to call the tool from this sentence (see Designing Good Tools). - Each property has its own
descriptionand the schema listsrequired.runToolCallrejects calls that do not match, so yourrunfunction can trust its input's shape (not its safety; see section 13).
9. Test It with Code
Two ways to test a server without an agent.
Without a process. createMcpServer returns plain logic, so you can
call handle directly in node --test. Save this as
test/mcp-server.test.js:
import { test } from "node:test";
import assert from "node:assert/strict";
import { createMcpServer } from "../src/mcp-server.js";
import { makeDemoTools } from "../src/demo-tools.js";
const server = createMcpServer(makeDemoTools(), { name: "test" });
const call = (id, name, args) =>
server.handle({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } });
test("a notification gets no reply", async () => {
assert.equal(await server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }), null);
});
test("tools/call returns a content array", async () => {
const reply = await call(1, "add", { a: 2, b: 3 });
assert.deepEqual(reply.result, { content: [{ type: "text", text: "5" }], isError: false });
});
test("bad arguments are a tool error, not a protocol error", async () => {
const reply = await call(2, "add", { a: "two" });
assert.equal(reply.error, undefined);
assert.equal(reply.result.isError, true);
});
test("an unknown tool is a protocol error (-32602)", async () => {
const reply = await call(3, "nope", {});
assert.equal(reply.error.code, -32602);
});
test("an unknown method is a protocol error (-32601)", async () => {
const reply = await server.handle({ jsonrpc: "2.0", id: 4, method: "nope/nothing" });
assert.equal(reply.error.code, -32601);
});
Run it:
node --test --test-reporter=spec test/mcp-server.test.js
# output
# ✔ a notification gets no reply
# ✔ tools/call returns a content array
# ✔ bad arguments are a tool error, not a protocol error
# ✔ an unknown tool is a protocol error (-32602)
# ✔ an unknown method is a protocol error (-32601)
# ℹ tests 5
# ℹ pass 5
# ℹ fail 0
(Timings are printed too in the real output and vary, so we left them out.) These tests encode the rules from sections 5 and 6, so a future edit cannot break them silently.
With a real process. This uses McpClient, which chapter 8 explains
in full. For now treat it as a helper that starts your server, does the
handshake, and gives you listTools and callTool:
import { McpClient } from "./src/mcp-client.js";
const client = await McpClient.connect({ command: "node", args: ["src/text-server.js"] });
console.log(client.serverInfo);
const tools = await client.listTools();
console.log(tools.map((t) => t.name));
console.log(await client.callTool("word_count", { text: "Phnom Penh is busy today" }));
console.log(await client.callTool("word_count", {}));
try {
await client.request("tools/call", { name: "nope" });
} catch (err) {
console.log("protocol error:", err.message);
}
client.close();
node try-server.js
# output
# MCP server ready (stdio)
# { name: 'text-server', version: '0.1.0' }
# [ 'word_count', 'to_upper' ]
# { text: '5 words', isError: false }
# { text: 'Error: missing required argument "text"', isError: true }
# protocol error: Unknown tool: nope (code -32602)
Notice the two kinds of failure again. A bad argument comes back as a
value with isError: true (the client does not throw). An unknown tool
makes the client's request throw, because the reply was a
protocol error.
10. The Config Shape Hosts Use
You rarely start a server yourself. A host application (a coding assistant, a chat desktop app, an editor) reads a config file that lists the servers to launch, and starts each one as a child process. Many hosts use this JSON shape:
{
"mcpServers": {
"demo": { "command": "node", "args": ["src/demo-server.js"] },
"text": { "command": "node", "args": ["src/text-server.js"] }
}
}
The key (demo, text) is a name you choose. command and args are
exactly what you would type in a terminal. Some hosts also accept an
env object for environment variables. The file name, its location and
the extra fields differ between products, so check your client's docs
for the exact file and format. Chapter 8 reads this same shape in our
own agent.
11. Real Projects Use the Official SDK
Our mcp-server.js is about sixty lines because it skips most of the
protocol. For real work, use an official SDK. Official SDKs exist
for several programming languages, and they handle the full protocol:
version negotiation, all message types, both transports, validation,
cancellation and more. They also track changes in the specification, so
you do not have to.
We are not pasting SDK code here, because it is not part of this
series' zero-dependency lab and we cannot run it in this chapter. The
good news is that everything you learned carries over. An SDK server
still has tools with a name, a description and a schema; it still
receives tools/call; it still must keep logs off stdout; it still has
the protocol-error versus tool-error split. Find the SDK for your
language and its examples at
modelcontextprotocol.io.
12. What We Skipped
Our server speaks a small slice of MCP. Things the real protocol has that we left out:
- Resources (read-only data a server can offer, like files or records) and prompts (reusable prompt templates).
- Sampling (a server asking the host's model to generate text).
- Authorization for remote servers.
- The HTTP transport for servers that run elsewhere (we use only stdio).
- Progress updates for long tasks, and cancellation of requests.
- Notifications such as "my tool list changed".
- Strict version negotiation (we echo the client's version).
If you build something real, you will meet some of these. Read the current specification for details; it changes, and we do not want to describe from memory something that may have moved.
13. Your Server Runs With Your Permissions
A stdio server is a program running on your computer, as you. When
a host launches node src/text-server.js, that process can do
whatever your user account can do: read your files, use your network,
read environment variables. The model does not run the code, but the
tool it calls does, and the model chooses the arguments.
So treat every tool as an entry point that strangers might reach through the model:
- Validate inputs yourself. The schema check catches wrong types
and missing fields. It does not know that
../../secretis a bad file path or that a URL points inside your network. Chapter 9 builds the guards. - Do the least. A tool called
read_noteshould read notes, not "any file". A tool that can run shell commands is a very large door. - Prefer read-only. Write and delete tools need extra care and, in chapter 9, a human approval step.
- Keep secrets out. Do not return passwords or tokens in results, because results go into the model's context.
- Only run servers you trust. Installing someone else's MCP server is installing their code with your permissions.
14. Recipe: The MCP Server Modules
All the files this chapter owns, in full. defineTool, runToolCall
and makeDemoTools come from
Tool Calling Basics.
src/mcp-server.js
import readline from "node:readline";
import { runToolCall } from "./tools.js";
// A minimal MCP server: JSON-RPC 2.0 messages, one per line, over stdio.
// It supports just enough of the protocol to expose tools.
export function createMcpServer(tools, { name = "agentlab-server", version = "1.0.0" } = {}) {
const ok = (id, result) => ({ jsonrpc: "2.0", id, result });
const fail = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
// Returns the response message, or null when no response is needed.
async function handle(msg) {
const { id, method, params } = msg;
if (id === undefined) return null; // a notification, such as notifications/initialized
switch (method) {
case "initialize":
return ok(id, {
protocolVersion: params?.protocolVersion ?? "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name, version },
});
case "ping":
return ok(id, {});
case "tools/list":
return ok(id, {
tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.parameters })),
});
case "tools/call": {
const tool = tools.find((t) => t.name === params?.name);
if (!tool) return fail(id, -32602, `Unknown tool: ${params?.name}`);
const call = { function: { name: tool.name, arguments: JSON.stringify(params.arguments ?? {}) } };
const { content, isError } = await runToolCall(call, tools);
return ok(id, { content: [{ type: "text", text: content }], isError });
}
default:
return fail(id, -32601, `Method not found: ${method}`);
}
}
return { handle };
}
export function serveStdio(server) {
const rl = readline.createInterface({ input: process.stdin });
const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
rl.on("line", async (line) => {
if (!line.trim()) return;
let msg;
try {
msg = JSON.parse(line);
} catch {
return send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
}
const reply = await server.handle(msg);
if (reply) send(reply);
});
// stdout is for protocol messages only. Log to stderr.
console.error("MCP server ready (stdio)");
}
src/demo-server.js: the five demo tools as an MCP server.
import { createMcpServer, serveStdio } from "./mcp-server.js";
import { makeDemoTools } from "./demo-tools.js";
serveStdio(createMcpServer(makeDemoTools(), { name: "demo-server" }));
src/text-server.js: your own server with custom tools (shown in
section 8, repeated here for the full file set).
import { createMcpServer, serveStdio } from "./mcp-server.js";
import { defineTool } from "./tools.js";
const tools = [
defineTool({
name: "word_count",
description: "Count the words in a piece of text",
parameters: {
type: "object",
properties: { text: { type: "string", description: "The text to count words in" } },
required: ["text"],
},
run: ({ text }) => {
const words = text.trim().split(/\s+/).filter(Boolean);
return `${words.length} words`;
},
}),
defineTool({
name: "to_upper",
description: "Convert text to upper case",
parameters: {
type: "object",
properties: { text: { type: "string", description: "The text to convert" } },
required: ["text"],
},
run: ({ text }) => text.toUpperCase(),
}),
];
serveStdio(createMcpServer(tools, { name: "text-server", version: "0.1.0" }));
Quick check that the demo server answers a ping:
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"ping"}' | node src/demo-server.js 2>/dev/null
# output
# {"jsonrpc":"2.0","id":1,"result":{}}
15. Try It Yourself
- Add a third tool to
text-server.jsnamedchar_count. Test it by hand withprintfand then withtry-server.js. - Make
handlereturn-32600("Invalid Request") when a message has nomethod. Write a test for it first. - Change
noisy-server.jsto log withconsole.error, then confirm that stdout contains only JSON lines. - Write a tool that throws an error (
throw new Error("boom")). Confirm the reply is a normal result withisError: true, not a protocol error. - Make the server compare
params.protocolVersionagainst a list of versions you support and answer with an error for others. Look up what the specification says a server should reply with. - Add a
tools/listtest that checks every tool has aninputSchemaof typeobject.
16. 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.
Is an MCP server just a web API?
Not quite. It speaks JSON-RPC and has a fixed handshake, and over stdio it is not a web server at all: no port, no URL, just a child process reading and writing lines. The idea is similar (a program offering operations to another program), but MCP standardises how the client discovers the tools and describes their inputs.
Why use stdio and not HTTP?
For local tools it is simpler: the host starts the process, so there is nothing to expose on the network, no port to protect and no authentication to build. For servers that live elsewhere, MCP also defines an HTTP-based transport. Check the specification for details.
Why must I keep logs off stdout?
Because stdout is the channel. Anything printed there is read as a
protocol message. A stray console.log turns a good message stream into
a broken one. Use stderr for logs.
What is the difference between isError and a JSON-RPC error?
A JSON-RPC error means the request itself was invalid: wrong method,
unknown tool, unreadable JSON. isError: true means the tool ran and
failed. The second one is meant to be read by the model so it can
recover.
Should I use the official SDK or write my own like this?
Use the SDK for anything real. Writing it yourself once is a great way to understand the protocol, which is why this chapter exists. In production you want the full protocol, validation and updates handled for you.
Is my server safe because the model only sends text?
No. The model sends arguments and your code does the action. If your tool deletes a file when asked, the model (or a text hidden in a web page the model read) can ask. The safety has to be in your tool and in the permissions around it.
Can one server serve many hosts?
A stdio server is started by one host and talks to that host only. If five hosts want it, each starts its own copy. A server reachable over HTTP can serve several clients.
Where to go next. You have a server. Next, in Connect an Agent to
MCP,
you read McpClient line by line, connect two servers at once, and run
your agent loop against their tools.
Buy Me a Coffee
Found this tutorial helpful? Consider buying me a coffee to support more free content!