# How to build an MCP server

AI · 2026-10-09 · 5 min read · by Matt Oddie
Canonical: https://mattoddie.dev/ai/building-an-mcp-server/

> Build an MCP server in TypeScript, from a one-tool example to patterns that scale to hundreds of tools: API clients, opt-in writes, trimmed output and toolsets.

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](https://unifi-mcp.mattoddie.dev), which has 132 tools for UniFi Network, and [proxmox-mcp](https://proxmox-mcp.mattoddie.dev), 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:

Terminal:

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

Then register one tool and connect over stdio:

src/index.ts:

```ts
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:

Terminal:

```sh
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 stdout.** In 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):

```ts
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.

*Figure 1: Every tool call passes the same checks in `defineTool` before the handler reaches the API through the shared client.*

Diagram: 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.

## 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.
