Development
This page is for people working on unifi-mcp itself. Start with CONTRIBUTING.md for the workflow, and use this page for how the code fits together.
You need Node.js 22 or later (CI tests 22 and 24) and Docker for building the image.
git clone https://github.com/mattoddie/unifi-mcp.gitcd unifi-mcpnpm cinpm test # type-checks, builds and runs all tests against a mock controller| Script | What it does |
|---|---|
npm run build |
Compile TypeScript from src/ to dist/ |
npm test |
Build, then run dist/test/*.test.js with the Node test runner |
npm start |
Run the built server (node dist/index.js) |
npm run docs:tools |
Regenerate docs/tools.md from the tool definitions |
npm run docs:check |
Fail if docs/tools.md is out of date (CI runs this) |
The project deliberately has only three runtime dependencies: @modelcontextprotocol/sdk, zod and undici. There’s no test framework, linter or bundler. Please discuss before adding a dependency.
Project layout
Section titled “Project layout”src/├── index.ts # entry point: load config, pick transport, handle signals├── config.ts # environment variables → typed Config (validation lives here)├── server.ts # builds the McpServer and registers every toolset and prompt├── http.ts # stateless Streamable HTTP transport, auth, Host allow-list, /health├── prompts.ts # the built-in MCP prompts├── unifi/│ ├── client.ts # UnifiClient: auth, sessions/CSRF, UniFi OS detection, errors│ └── format.ts # summarizers (devices, clients, ports…), redaction, unit helpers├── tools/│ ├── util.ts # defineTool() and shared argument schemas│ ├── rest.ts # CRUD helpers for classic REST, v2 and settings endpoints│ └── <toolset>.ts # one file per toolset: overview, devices, clients, …├── scripts/│ └── gen-tool-docs.ts└── test/ ├── mock-controller.ts # an in-process fake UniFi controller ├── helpers.ts # connect an MCP client to a server, start the mock └── *.test.tsHow a request flows
Section titled “How a request flows”- An MCP client calls a tool, for example
unifi_list_devices {type: "switch"}. - The MCP SDK validates the arguments against the tool’s zod schema.
- The handler registered by
defineTool()runs. It callsUnifiClient, usually through the helpers intools/rest.ts. UnifiClient.request()adds the/proxy/networkprefix on UniFi OS, authenticates (API key, or session cookie plus CSRF, logging in again on 401), sends the request and unwraps the{meta, data}envelope. It turns failures into aUnifiErrorwith a helpful hint.- The handler summarises the result (
unifi/format.ts), redacts secrets and returns plain data. defineTool()serialises that to JSON text. A thrown error becomesisError: truewith the message, so the model can see what went wrong and adjust.
Adding a tool
Section titled “Adding a tool”Tools are defined with defineTool() in the file for their toolset. Here’s a complete example:
defineTool(ctx, "unifi_save_bandwidth_profile", { toolset: "networks", // which UNIFI_TOOLSETS entry enables it title: "Create/update bandwidth profile", // short human title description: "Create (no id) or update (id) a bandwidth profile (user group). Rates are in kbps; -1 means unlimited.", write: true, // only registered with UNIFI_ALLOW_WRITES=true input: { ...siteArg, id: idArg("Bandwidth profile").optional().describe("Profile ID to update; omit to create"), name: z.string().min(1).optional().describe("Profile name (required on create)"), download_kbps: z.number().int().min(-1).optional().describe("Download cap in kbps (-1 = unlimited)"), upload_kbps: z.number().int().min(-1).optional().describe("Upload cap in kbps (-1 = unlimited)"), }, handler: async (a) => { if (!a.id && !a.name) throw new Error("name is required when creating a bandwidth profile"); const body = defined({ name: a.name, qos_rate_max_down: a.download_kbps, qos_rate_max_up: a.upload_kbps }); return summarizeUsergroup(await restSave(client, site(a.site), "usergroup", a.id, body)); },});The defineTool() options that control registration:
| Option | Effect |
|---|---|
toolset |
Required. The tool is only registered when this toolset is enabled. |
write: true |
The tool changes state. Only registered when writes are enabled. Marked readOnlyHint: false. |
delete: true |
The tool deletes configuration. Only registered when deletes are enabled too. Marked destructive. |
destructive: true |
Marks a write tool as destructive (destructiveHint), so clients confirm before running it. Use it for anything that disconnects clients, reboots devices or changes configuration. |
readOnly |
Overrides the read-only hint. This is rarely needed. |
Checklist for a new tool:
- Put it in the right
src/tools/<toolset>.ts. If it needs a new toolset, add it toTOOLSETSinconfig.tsand register it inserver.ts. - Use the shared arguments (
siteArg,idArg,macArg,searchArg,limitArg,extraArg) so tools behave consistently. - For configuration objects, follow the
save_*convention: noidcreates,idupdates only the fields passed (restSave/v2Savedo this), and offerextrafor raw fields. - Return a compact summary and offer
raw: truefor the full object. Run anything that might contain secrets throughredactSecrets(). - Write descriptions for the model: say what the tool does, when to use it, units, side effects (“clients will briefly disconnect”), and any version requirement (“Network 9.0+”).
- Add tests in
src/test/using the mock controller. - Run
npm run docs:toolsand commit the updateddocs/tools.md. If it’s a notable feature, update the README table and CHANGELOG.
Tool design guidelines
Section titled “Tool design guidelines”- Fewer, broader tools beat many narrow ones. One
save_wlanwith optional fields is easier for a model thancreate_wlan,rename_wlanandset_wlan_password. - Never surprise. Updates only change what was asked. Destructive actions are flagged. Deletes are behind their own switch.
- Errors should teach. Throw messages that tell the model, or the user, how to fix the call: “name is required when creating a bandwidth profile”.
- Be honest about uncertainty. UniFi’s internal APIs aren’t documented. If you aren’t sure of a field name across versions, say so in the description and point to
extra.
Testing
Section titled “Testing”Tests use Node’s built-in test runner against MockController (src/test/mock-controller.ts), a small HTTP server that imitates a UniFi OS console or legacy controller:
- Built-in login flows for both controller types, sessions, CSRF checks and API keys
- A generic in-memory CRUD store for
rest/<collection>andv2/<collection>, which you fill withseed() on(method, regex, handler)to add custom routes, which take priority over the built-in onesrequestsTo(prefix)to check what the server sent
A typical test:
import { connect, parse, startMock } from "./helpers.js";
it("creates a bandwidth profile", async () => { const { mock, client, stop } = await startMock(); mock.seed("rest", "usergroup", []); const mcp = await connect(client, true); // true = writes enabled const res = parse(await mcp.callTool({ name: "unifi_save_bandwidth_profile", arguments: { name: "Kids", download_kbps: 10000 } })); assert.equal(res.name, "Kids"); assert.equal(mock.requestsTo("/api/s/default/rest/usergroup")[0].body.qos_rate_max_down, 10000); await mcp.close(); await stop();});Run a single file with npm run build && node --test dist/test/networks.test.js.
Running against a real controller
Section titled “Running against a real controller”With the MCP Inspector:
npm run buildUNIFI_URL=https://192.168.1.1 UNIFI_API_KEY=... npx @modelcontextprotocol/inspector node dist/index.jsOr build the image and point Claude Code at it:
docker build -t unifi-mcp:dev .docker run --rm -p 8080:8080 --env-file .env unifi-mcp:devclaude mcp add --transport http unifi-dev http://localhost:8080/mcp --header "Authorization: Bearer $MCP_AUTH_TOKEN"Please test write tools on a lab site or a non-critical object, and take a backup first.
Docs that are generated
Section titled “Docs that are generated”docs/tools.md is generated by src/scripts/gen-tool-docs.ts. The script starts the server in-process with every toolset, writes and deletes enabled, and lists the registered tools and prompts. Don’t edit docs/tools.md by hand. Change the tool’s title, description or argument .describe() text and run npm run docs:tools. CI fails if the committed file is stale.
The website
Section titled “The website”The site at unifi-mcp.mattoddie.dev lives in website/. It has a hand-built landing page plus these docs rendered with Astro Starlight. It has its own package.json, so its dependencies never reach the server or the Docker image. The Markdown in docs/ is the single source: edit it here and the site picks it up on the next build.