Build an MCP Server

ជំពូកនេះមិនទាន់ត្រូវបានបកប្រែជាភាសាខ្មែរនៅឡើយទេ — សូមអានជាភាសាអង់គ្លេសខាងក្រោម។

Table of Contents

  1. What We Are Building
  2. Messages Are Lines
  3. The Server, Step by Step
  4. Drive It by Hand
  5. Protocol Errors and Tool Errors
  6. Notifications Get No Reply
  7. Never Print Logs to stdout
  8. Add Your Own Tool: word_count
  9. Test It with Code
  10. The Config Shape Hosts Use
  11. Real Projects Use the Official SDK
  12. What We Skipped
  13. Your Server Runs With Your Permissions
  14. Recipe: The MCP Server Modules
  15. Try It Yourself
  16. 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:

  1. reads JSON-RPC messages from its standard input,
  2. answers initialize, tools/list and tools/call,
  3. 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 id and a method. It expects a reply with the same id.
  • Response: has the id of the request, and either a result or an error.
  • Notification: has a method but no id. 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. Add 2>/dev/null to hide it.
  • The reply to id 1 is the handshake answer.
  • There is no reply for the notifications/initialized line. That is correct (section 6).
  • The reply to id 2 lists both tools with their inputSchema.
  • The reply to id 3 carries "4 words" inside a content array.

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 error field with a numeric code.
  • 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 result with isError: true, and the message is in content.

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}}
InputReplyKind
this is not json-32700 Parse error (id is null, because we could not read an id)protocol error
nope/nothing-32601 Method not foundprotocol error
unknown tool name-32602 Unknown tool (code means "invalid params")protocol error
missing argumentnormal result, isError: truetool error
valid callnormal result, isError: falsesuccess

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 description is 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 description and the schema lists required. runToolCall rejects calls that do not match, so your run function 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 ../../secret is a bad file path or that a URL points inside your network. Chapter 9 builds the guards.
  • Do the least. A tool called read_note should 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

  1. Add a third tool to text-server.js named char_count. Test it by hand with printf and then with try-server.js.
  2. Make handle return -32600 ("Invalid Request") when a message has no method. Write a test for it first.
  3. Change noisy-server.js to log with console.error, then confirm that stdout contains only JSON lines.
  4. Write a tool that throws an error (throw new Error("boom")). Confirm the reply is a normal result with isError: true, not a protocol error.
  5. Make the server compare params.protocolVersion against a list of versions you support and answer with an error for others. Look up what the specification says a server should reply with.
  6. Add a tools/list test that checks every tool has an inputSchema of type object.

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.

ទិញកាហ្វេជូនខ្ញុំ

ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!