# mattoddie.dev > Matt Oddie's blog about building things: home lab networking with UniFi and Proxmox, NixOS, and AI tooling such as MCP servers. # 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. --- # Moving from Homebrew to NixOS Home Lab · 2026-10-07 · 3 min read · by Matt Oddie Canonical: https://mattoddie.dev/homelab/homebrew-to-nixos/ > How to move from Homebrew to NixOS: turn a Brewfile into a flake, map packages, replace brew services with systemd and give each project its own dev shell. Homebrew keeps a machine's tools in a list you add to over time. NixOS keeps the whole machine in a file you rebuild from. Moving between them is mostly a translation job, and it goes faster if you treat it as one. ## Take an inventory Before installing anything, write down what Homebrew is doing for you today. `brew bundle dump` writes every tap, formula, cask and Mac App Store app to a Brewfile, which becomes the checklist for the rest of the move. On the old machine: ```sh # Everything installed, as a checklist brew bundle dump --file=Brewfile # Only the formulae you asked for, not their dependencies brew leaves # Background services Homebrew is running brew services list ``` `brew leaves` matters more than the full list. Dependencies come along automatically in Nix as well, so you only need to translate the packages you chose. In a typical Brewfile that removes well over half the lines. ## Map the names Most command-line tools have the same name in nixpkgs. The exceptions are versioned formulae, where Homebrew uses `@` and nixpkgs uses an underscore or a separate attribute. [search.nixos.org](https://search.nixos.org/packages) or `nix search nixpkgs ` settles any doubt. | Homebrew | nixpkgs | Where it goes | | --- | --- | --- | | `ripgrep`, `fd`, `jq`, `gh` | same names | `home.packages` | | `node@22` | `nodejs_22` | project dev shell | | `python@3.12` | `python312` | project dev shell | | `awscli` | `awscli2` | `home.packages` | | `postgresql@16` + `brew services` | `services.postgresql` | `configuration.nix` | | `git` and its config | `programs.git` | home-manager | | casks such as `visual-studio-code` | `vscode` (unfree) | `home.packages` | The third column is the real decision. NixOS gives you three places to put a package, and choosing well keeps the configuration tidy. - **System configuration** for services and anything every user needs: databases, Docker, SSH, fonts. - **home-manager** for your own tools and dotfiles. It replaces both `brew install` and the dotfiles repo you were symlinking by hand. - **Per-project dev shells** for language runtimes. A project pins its own Node or Python version, so nothing needs to be installed globally. ## Start from a flake A flake pins the exact nixpkgs revision in `flake.lock`, which is the equivalent of Homebrew never upgrading anything until you say so. This is a minimal one that wires home-manager into the system build, so one command updates both. flake.nix: ```nix { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; home-manager.url = "github:nix-community/home-manager/release-26.05"; home-manager.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = { nixpkgs, home-manager, ... }: { nixosConfigurations.workbench = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ ./configuration.nix home-manager.nixosModules.home-manager { home-manager.useGlobalPkgs = true; home-manager.useUserPackages = true; home-manager.users.matt = import ./home.nix; } ]; }; }; } ``` The Brewfile's command-line tools then become a list in `home.nix`, and tools with configuration get a `programs.*` module instead of a dotfile: home.nix: ```nix { pkgs, ... }: { home.stateVersion = "26.05"; home.packages = with pkgs; [ ripgrep fd jq gh awscli2 vscode ]; programs.git = { enable = true; settings.user.name = "Matt Oddie"; settings.init.defaultBranch = "main"; }; programs.direnv = { enable = true; nix-direnv.enable = true; }; } ``` ## Replace brew services with systemd Everything in `brew services list` becomes a NixOS service option. These are better than their Homebrew equivalents in one respect: the service, its config file, its user and its data directory all come from the same few lines, so rebuilding a machine brings the whole service back. configuration.nix (excerpt): ```nix { pkgs, ... }: { nixpkgs.config.allowUnfree = true; # vscode and friends services.postgresql = { enable = true; package = pkgs.postgresql_16; ensureDatabases = [ "app_dev" ]; }; virtualisation.docker.enable = true; users.users.matt.extraGroups = [ "docker" ]; } ``` ## Move runtimes into projects With Homebrew, one version of Node serves every project on the machine. On NixOS each project can carry a small flake with a `devShells.default` listing what it needs, and direnv loads it when you `cd` into the directory. Adding `use flake` to the project's `.envrc` is the whole setup. Two projects on different Node versions stop being a problem you manage. If you already use mise or asdf with a `.tool-versions` file, that keeps working on NixOS too. It's a reasonable halfway step while you convert projects one at a time. > **Downloaded binaries won't run.** NixOS has no `/lib64/ld-linux-x86-64.so.2`, so prebuilt binaries from release pages, npm postinstall scripts and editor extensions fail with "No such file or directory". Set `programs.nix-ld.enable = true;` in the system configuration and most of them start working. ## What doesn't translate - **Mac App Store entries** in the Brewfile have no equivalent. Find the Linux version or an alternative for each. - **Apps that update themselves** fight the read-only Nix store. Install them from nixpkgs and let the flake update them, or run them as a Flatpak. - **Taps** for niche tools sometimes have no nixpkgs package. Check for a flake in the tool's repository first, since many projects now ship one, and package it yourself only as a last resort. ## The new upgrade loop `brew update && brew upgrade` becomes two commands. Update the lock file, then rebuild: In the flake directory: ```sh nix flake update sudo nixos-rebuild switch --flake .#workbench # Something broke? Go back to the previous generation sudo nixos-rebuild switch --rollback ``` The rollback is what you couldn't do with Homebrew. Every rebuild is a generation you can return to from the command line or the boot menu, so an upgrade that breaks something takes one command to undo. Keep the flake in Git and work down the Brewfile line by line. Once every entry is either translated or deliberately dropped, the move is done. --- # Setting up a lab network with UniFi Home Lab · 2026-10-05 · 4 min read · by Matt Oddie Canonical: https://mattoddie.dev/homelab/unifi-lab-network/ > Set up a UniFi lab network: a separate VLAN for lab machines, switch port settings for access and trunk ports, and a firewall zone that keeps it isolated. A lab network is a place where you can break things without breaking anything else. In UniFi that comes down to three pieces: a virtual network with its own VLAN, switch ports that put machines on it, and a firewall zone that decides what it can reach. This walk-through uses the UniFi Network application on a UniFi OS gateway with the zone-based firewall, which arrived in Network 9.0. The menu names below are from the current interface. Older versions with the legacy firewall can do the same thing with LAN In rules, but the steps differ. ## Plan the addresses first Pick a VLAN ID and a subnet before opening the controller, and write them down. Matching the third octet to the VLAN ID makes packet captures and firewall logs easier to read at a glance. | Setting | Value | Notes | | --- | --- | --- | | Name | Lab | Shown in client lists and firewall zones | | VLAN ID | `40` | Any unused ID from 2 to 4009 | | Gateway / subnet | `10.40.0.1/24` | 254 usable addresses | | Static range | `10.40.0.2 – .99` | Hypervisors, switches, anything you SSH to by address | | DHCP range | `10.40.0.100 – .249` | Short-lived VMs and containers | | Domain | `lab.home.arpa` | `home.arpa` is reserved for this by RFC 8375 | ## Create the network In UniFi Network, go to **Settings → Networks → New Virtual Network**. Give it the name, set the gateway IP and subnet, and open the advanced settings to set the VLAN ID manually. Leave DHCP mode as *DHCP Server* and set the range so it doesn't overlap your static block. Set the domain name in the DHCP options too. Clients then get `lab.home.arpa` as their search domain, so `ssh pve1` works without the full name once you add local DNS records for your static hosts. If your version shows an *Isolate Network* option, leave it off. It blocks traffic between this network and every other one, including the traffic you want from your laptop. The firewall zone below gives you the same protection with one exception you control. ## Put ports on the VLAN A network exists on the gateway as soon as you save it. Machines join it through switch ports, and there are two kinds you need. ### Access ports For a machine that only ever lives on the lab network, such as a spare mini PC, open the switch in **UniFi Devices**, select the port in the Port Manager and set its *Native VLAN / Network* to Lab. Set tagged VLAN management to *Block All* so the port carries nothing else. The machine needs no VLAN configuration of its own. ### Trunk ports A hypervisor is different. Its own management address can stay on your main network while its VMs sit on Lab, so the port keeps your normal native network and carries Lab as a tagged VLAN. On Proxmox that means a VLAN-aware bridge, and each VM's network device gets VLAN tag 40. /etc/network/interfaces on the Proxmox host: ``` auto vmbr0 iface vmbr0 inet static address 192.168.1.20/24 gateway 192.168.1.1 bridge-ports eno1 bridge-stp off bridge-fd 0 bridge-vlan-aware yes bridge-vids 2-4094 ``` > **Keep a way back in.** Don't move the switch's own management address onto the lab VLAN while you're connected through it. If the firewall rules aren't in place yet you lose the controller's path to the switch, and the fix is a factory reset. Change ports one at a time and check each before moving on. ## Give it a firewall zone The zone-based firewall groups networks into zones and sets one policy for each pair of zones. New networks land in the *Internal* zone, where everything can talk to everything. Open the zone settings in the firewall section of UniFi Network, create a custom zone called Lab and move the Lab network into it. Then set the policies between Lab and the other zones: - **Internal → Lab: allow.** Your laptop can reach lab machines over SSH, the Proxmox web UI and anything else you run there. - **Lab → Internal: block.** A misbehaving VM can't scan or reach your other devices. Keep return traffic enabled on the Internal → Lab allow policy, so replies to connections you start still get back. - **Lab → External: allow.** Lab machines need the internet for package updates and container images. - **Lab → Gateway: allow DNS and DHCP only.** Restricting this stops lab machines from reaching the gateway's own management interface. *Figure 1: Zone policies for the lab network. The only way in from home devices is a connection you start yourself.* Diagram: Firewall zones: Internal can reach Lab, Lab cannot reach Internal, and Lab can reach External and the gateway for DNS and DHCP. ## Check it from both sides From a machine on the lab network, confirm the address, the gateway and the block: On a lab host: ```sh # Address from the right range, search domain set ip -brief addr cat /etc/resolv.conf # Gateway and the internet work ping -c 3 10.40.0.1 curl -sI https://deb.debian.org | head -1 # A device on the main network should time out ping -c 3 -W 2 192.168.1.50 ``` Then from your laptop on the main network, SSH to a lab host by name. If that works and the last ping above fails, the lab is in place. Anything you start on VLAN 40 from now on can only reach the internet and the gateway's DNS, so you can try things out there without risking the rest of the network. If you use [unifi-mcp](https://unifi-mcp.mattoddie.dev), a question like "which clients are on the Lab network?" is a quick way to see what has actually landed on the VLAN. That server, and how it was built, is the subject of a [post in the AI category](https://mattoddie.dev/ai/building-an-mcp-server/).