mattoddie.dev

Notes on things I'm building and things I'm interested in.

PORT 02 AI 2026-10-09 · 5 min /ai/building-an-mcp-server/
Port 02 · AI · 5 min

How to build an MCP server

An MCP server is a small program that offers tools to an AI assistant. The assistant reads each tool's name, description and input schema, decides when to call it, and gets back text it can reason about. Building one takes about twenty lines. Building one that a model uses well takes some design.

This post starts with the twenty lines, then covers the patterns behind two larger servers: unifi-mcp, which has 132 tools for UniFi Network, and proxmox-mcp, which has more than 300 for Proxmox VE. Both are TypeScript on the official SDK.

What a server is made of

The Model Context Protocol is JSON-RPC between a client (Claude Code, Claude Desktop, an IDE) and your server. A server can offer three kinds of thing:

  • Tools are functions the model calls, such as list_clients or restart_device. Most servers are mostly tools.
  • Prompts are reusable templates the user picks, such as "health check my network".
  • Resources are documents the client can read into context.

Messages travel over one of two transports. stdio runs the server as a child process of the client, which suits local tools. Streamable HTTP runs it as a long-lived service that clients connect to, which suits a container on another machine.

The smallest useful server

Install the SDK and zod, which the SDK uses for input schemas:

TerminalShell
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

Then register one tool and connect over stdio:

src/index.tsTypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "time-mcp", version: "0.1.0" });

server.registerTool(
  "get_time",
  {
    title: "Get the current time",
    description: "Current date and time in an IANA time zone.",
    inputSchema: {
      tz: z.string().describe("IANA time zone, e.g. Europe/London"),
    },
    annotations: { readOnlyHint: true },
  },
  async ({ tz }) => ({
    content: [{ type: "text", text: new Date().toLocaleString("en-GB", { timeZone: tz }) }],
  }),
);

await server.connect(new StdioServerTransport());

Build it with tsc, then try it in the MCP Inspector before involving a model. The Inspector lists your tools, shows the schema the client will see, and lets you call each one by hand:

TerminalShell
npx @modelcontextprotocol/inspector node dist/index.js

# Then add it to Claude Code
claude mcp add time -- node /path/to/dist/index.js

Never log to stdoutIn stdio mode, stdout is the protocol channel. A stray console.log corrupts the JSON-RPC stream and the client drops the connection with an unhelpful error. Send all logging to stderr with console.error.

Patterns for a real server

One tool wrapping a built-in function is easy. A server that wraps a whole API needs more structure, and the same five patterns came up in both larger servers.

1. Put the API behind a client class

Tools shouldn't know about authentication, base paths or retries. A client class handles those once. In unifi-mcp it sends either an X-API-KEY header or a session cookie, and adds the /proxy/network prefix that UniFi OS consoles need but self-hosted controllers don't. Tools then call client.siteGet("default", "stat/sta") and never think about which kind of controller is on the other end. The same split makes testing easy, because the test suite points the client at a mock API.

2. Register every tool through one helper

Calling registerTool directly a few hundred times leads to a few hundred slightly different error formats. Both servers use a single defineTool helper that sets the annotations, wraps errors, serialises the result and decides whether the tool should exist at all:

src/tools/util.ts (simplified)TypeScript
export function defineTool(ctx, name, def) {
  if (!ctx.toolsets.has(def.toolset)) return;
  if (def.write && !ctx.allowWrites) return;
  if (def.delete && !ctx.allowDeletes) return;

  ctx.server.registerTool(name, {
    title: def.title,
    description: def.description,
    inputSchema: def.input,
    annotations: {
      readOnlyHint: !def.write,
      destructiveHint: def.write ? Boolean(def.destructive || def.delete) : undefined,
    },
  }, async (args) => {
    try {
      const data = await def.handler(args);
      return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
    } catch (err) {
      return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
    }
  });
}

Returning errors as isError: true results, instead of throwing, matters more than it looks. The model sees the message ("Unknown site 'defualt'") and can correct itself on the next call.

3. Make writes opt-in, and leave disabled tools out

Look at the first three lines of the helper. When writes are off, write tools are never registered, so the model doesn't know they exist. That beats registering them and refusing at call time. A tool the model can see is a tool it will try, and every refusal costs a round trip and some of the user's patience. Both servers start read-only, need one environment variable for writes and a second for deletes. proxmox-mcp has a third switch for running commands inside guests.

The readOnlyHint and destructiveHint annotations then let the client decide when to ask the user before running a tool, so even an enabled delete gets a confirmation prompt.

4. Return less than the API gives you

A UniFi client record has dozens of fields, most of them radio statistics. Two hundred of those fill a context window with numbers nobody asked about. Each list tool in unifi-mcp maps records through a summary function that keeps the fields people ask about: name, IP, MAC, network, signal, uptime. A raw argument returns the full objects for the rare question that needs them. List tools also take search and limit arguments, and return a total alongside the items so the model knows when it's looking at a partial list.

5. Group tools into toolsets

Every registered tool's name, description and schema is sent to the model, and 300 of them is a lot of context before the user has typed anything. Both servers group tools into toolsets (devices, clients, firewall, VPN and so on) and take a comma-separated list to enable, such as UNIFI_TOOLSETS=overview,devices,clients. Someone who only asks about WiFi doesn't pay for the firewall tools.

A request flows from the MCP client to the server, through defineTool, the toolset and write checks, the handler and the API client, to the controller API. MCP CLIENT Claude Code MCP SERVER DEFINETOOL toolset, writes API CLIENT auth, base path CONTROLLER REST API stdio or HTTP
FIG 1Every tool call passes the same checks in defineTool before the handler reaches the API through the shared client.

Write descriptions for the model

The model chooses tools from their descriptions, so they deserve the same care as the code. Say what the tool returns as well as what it does ("with IP, network, AP or switch port, signal and uptime"). Use .describe() on every input, including an example value. When two tools are easy to confuse, such as currently connected clients and every client ever seen, each description should say which one it is.

The server as a whole can also pass instructions when it's created. unifi-mcp uses them to say where to start ("Start with unifi_get_site_health, unifi_list_devices or unifi_list_clients"), how the save tools behave, and whether writes are on. That one paragraph saves the model several exploratory calls at the start of every conversation.

Shipping it

For a server that talks to something on your network, a container running Streamable HTTP is the easiest to live with. Both servers run stateless, creating a fresh server and transport for each request, so restarting the container never strands a client session. They require a bearer token on /mcp and expose a /health endpoint for Docker's health check. The same image also runs in stdio mode with docker run -i for anyone who would rather not run a service.

Start with the Inspector and one read-only tool. Write the descriptions as if you were the model, and only turn on writes once reading works well.

Written by · Markdown version