> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hashgrid.net/llms.txt
> Use this file to discover all available pages before exploring further.

# API

# UniFi MCP Server API Documentation

This document provides comprehensive documentation for the Model Context Protocol (MCP) server that exposes UniFi Network Controller functionality. The server enables AI agents and other applications to interact with UniFi network infrastructure in a standardized way.

## Table of Contents

* [Overview](#overview)
* [Configuration](#configuration)
* [Connecting to the cloud API and local gateways together](#connecting-to-the-cloud-api-and-local-gateways-together)
* [Authentication](#authentication)
* [Performance Tracking](#performance-tracking)
* [MCP Tools](#mcp-tools)
* [MCP Resources](#mcp-resources)
* [MCP Prompts](#mcp-prompts)
* [A2A Endpoints](#a2a-endpoints)
* [A2A Error Handling](#a2a-error-handling)
* [Resource Error Handling](#resource-error-handling)
* [Rate Limiting](#rate-limiting)
* [Examples](#examples)

## Overview

The UniFi MCP Server bridges the gap between AI applications and UniFi Network Controllers by exposing network management capabilities as MCP tools and resources. It handles authentication, request formatting, and error handling, providing a clean interface for network automation.

This API reference is aligned to the current implementation baseline and the forward architecture described in `SPEC.md`.

## Operator usage

### Objective

Use this reference to verify the exposed MCP surface before changing configs, writing tests, or planning a rollout.

### Prerequisites

* You know the target API mode and transport mode.
* You know whether you need read-only verification or a write-path review.
* You have the current phase docs open alongside this reference.

### Procedure

1. Start with configuration and authentication to confirm the runtime assumptions.
2. Read the feature surface that matches the current deployment mode.
3. Cross-check any roadmap-dependent claims against `SPEC.md`, `DEVELOPMENT_PLAN.md`, and the phase runbooks.
4. Use the examples and endpoint listings to validate the intended behavior before changing production state.

### Verification

* The documented endpoint or tool exists in the current implementation.
* The reference does not overstate future features as live.
* The operator can map the doc to an actual test or validation step.

### Rollback

* If the API reference drifts from the implementation, restate the supported surface and move aspirational material back to roadmap docs.

### Common failure modes

* Treating roadmap language as if it were an implemented guarantee.
* Skipping the phase docs and relying on the API reference alone.
* Confusing future transport or auth modes with currently shipped behavior.

### Architecture

```
┌─────────────────┐
│  AI Application │
│  (Claude, etc.) │
└────────┬────────┘
         │ MCP Protocol
         │
┌────────▼────────┐
│   UniFi MCP     │
│     Server      │
└────────┬────────┘
         │ UniFi API
         │
┌────────▼────────┐
│  UniFi Network  │
│   Controller    │
└─────────────────┘
```

### Key Features

* **Device Management:** List, configure, and monitor UniFi devices
* **Network Configuration:** Manage networks, VLANs, and subnets
* **Client Information:** Query connected clients and their status
* **Firewall Rules:** Create and manage firewall rules
* **Site Management:** Work with multiple UniFi sites
* **Real-time Monitoring:** Access device and network statistics
* **Roadmap alignment:** native Protect, Access, multi-controller, dry-run, RBAC, audit, metrics, A2A, and webhooks are defined in `SPEC.md`

## Configuration

### Environment Variables

Configure the MCP server using environment variables:

> Note: the table below includes both currently supported settings and planned controls that will become active as Phase 5 lands.
> \| Variable | Description | Required | Default |
> \|----------|-------------|----------|---------|
> \| `UNIFI_API_KEY` | UniFi API Key (used for both Network and Site Manager APIs) | Yes | - |
> \| `UNIFI_API_TYPE` | API type: `cloud-ea`, `cloud-v1`, or `local` | No | `cloud-ea` |
> \| `UNIFI_MIXED_MODE` | Expose the cloud **and** local tool modules together, for a fleet whose registry holds both cloud controllers and local gateways. Without it the exposed modules follow the single `UNIFI_API_TYPE`, so local-only tools are missing on a cloud-typed server even when a local gateway is registered. Needs no `UNIFI_LOCAL_HOST`: the gateways come from the registry. See [Connecting to the cloud API and local gateways together](#connecting-to-the-cloud-api-and-local-gateways-together) | No | `false` |
> \| `UNIFI_LOCAL_HOST` | Local gateway hostname or IP (required when `UNIFI_API_TYPE=local`) | No\* | - |
> \| `UNIFI_LOCAL_PORT` | Local gateway port | No | `443` |
> \| `UNIFI_LOCAL_VERIFY_SSL` | Verify the local gateway's TLS certificate. Setting `false` logs a startup warning; prefer `UNIFI_LOCAL_CA_FILE` for self-signed certs | No | `true` |
> \| `UNIFI_LOCAL_CA_FILE` | CA certificate (PEM) to verify the local gateway with, in place of the default roots. Checked at startup. Can be the console's own self-signed certificate | No | - |
> \| `UNIFI_LOCAL_TLS_SERVER_NAME` | Name to check the local gateway's certificate against, and send as SNI, when it is not `UNIFI_LOCAL_HOST`. UniFi OS's own certificate names `unifi.local`, not the console's address | No | - |
> \| `UNIFI_CLOUD_API_URL` | UniFi Cloud API base URL | No | `https://api.ui.com` |
> \| `UNIFI_DEFAULT_SITE` | Default site ID | No | `default` |
> \| `UNIFI_RATE_LIMIT_REQUESTS` | Max requests per rate limit period | No | `100` |
> \| `UNIFI_RATE_LIMIT_PERIOD` | Rate limit period in seconds | No | `60` |
> \| `UNIFI_REQUEST_TIMEOUT` | Request timeout in seconds | No | `30` |
> \| `UNIFI_MAX_RETRIES` | Maximum retry attempts | No | `3` |
> \| `UNIFI_RETRY_BACKOFF_FACTOR` | Exponential backoff factor between retries | No | `2.0` |
> \| `UNIFI_RETRY_TOTAL_TIMEOUT` | Wall-clock budget in seconds for one request, including all retries and backoff waits | No | `60` |
> \| `UNIFI_PROFILE` | Tool exposure profile: `network`, `devices`, `security`, `system`, `minimal`, `protect`. An unrecognised value logs a warning and falls back to all modules | No | unset |
> \| `UNIFI_READ_ONLY` | Register only non-mutating tools. State-changing tools are not exposed to the client at all | No | `false` |
> \| `UNIFI_DRY_RUN` | Change-safe mode: every tool with a `dry_run` parameter runs as a preview, and mutating tools without one are not registered | No | `false` |
> \| `UNIFI_AUDIT_LOG_ENABLED` | Write an audit record for mutating operations | No | `true` |
> \| `UNIFI_AUDIT_LOG_PATH` | Append-only audit log path. Relative paths resolve against the process working directory, which is not predictable under stdio transport — prefer an absolute path. The file is created mode 0600 | No | `audit.log` |
> \| `UNIFI_AUDIT_FAIL_CLOSED` | Refuse a mutating tool call (and a read, with `UNIFI_AUDIT_READS`) when its audit record cannot be written. Set `false` only as break-glass | No | `true` |
> \| `UNIFI_AUDIT_READS` | Also audit read tool calls: `attempt` and outcome records with the principal, tool, arguments and result, never the response payload | No | `false` |
> \| `UNIFI_AUDIT_CHAIN_KEY` | HMAC key for the audit hash chain; without it the chain uses plain SHA-256, which detects edits but can be recomputed by anyone who can write the file | No | unset |
> \| `UNIFI_AUDIT_SIEM_TARGETS` | Comma-separated SIEM destinations for stored audit records: `syslog+tls://host:6514`, `https://host/path` (signed webhook), `otlp+https://collector:4318` (see SECURITY.md). Requires `UNIFI_AUDIT_CHAIN_KEY` | No | unset |
> \| `UNIFI_AUDIT_SIEM_WEBHOOK_SECRET` | HMAC-SHA256 key signing webhook deliveries; required for `https://` targets | With a webhook | unset |
> \| `UNIFI_AUDIT_SIEM_OTLP_HEADERS` | Extra headers for OTLP targets, `key=value,key=value` | No | unset |
> \| `UNIFI_AUDIT_SIEM_CA_FILE` | CA bundle used to verify SIEM targets | No | system roots |
> \| `UNIFI_AUDIT_SIEM_BUFFER_DIR` | On-disk buffer for undelivered records | No | `siem-buffer` next to the audit log |
> \| `UNIFI_AUDIT_SIEM_BUFFER_MAX_MB` | Most undelivered data kept per target; the oldest is dropped beyond it | No | `100` |
> \| `UNIFI_AUDIT_LOG_ROTATE_MB` | Rotate the JSONL audit file at this size into `<log>.<UTC time>Z` files; the chain continues across them | No | unset (never rotate) |
> \| `UNIFI_AUDIT_RETENTION_DAYS` | Archive and delete audit records older than this: Postgres with `python -m src.fleet.cli audit-retention --confirm`, rotated JSONL files with `python -m src.utils.audit_retention LOG --confirm` (see SECURITY.md) | No | unset (keep forever) |
> \| `UNIFI_AUDIT_ARCHIVE_DIR` | Where retention writes its archives | No | `audit-archive` next to the audit log |
> \| `UNIFI_AUDIT_SIEM_ANCHOR_SECONDS` | How often each chain's head is sent to the SIEM as an anchor | No | `300` |
> \| `DATABASE_URL` | Postgres URL of the fleet controller registry. When set, controllers come from Postgres instead of the `UNIFI_*` settings (needs the `[fleet]` extra and `python -m src.fleet.cli migrate`) | No | unset |
> \| `UNIFI_FLEET_CREDENTIAL_KEY` | Encrypts the controller API keys stored in Postgres; required with `DATABASE_URL` | With `DATABASE_URL` | unset |
> \| `UNIFI_FLEET_REGISTRY_REFRESH_SECONDS` | How often each server reloads the registry from Postgres | No | `30` |
> \| `UNIFI_FLEET_MAX_OPEN_CLIENTS` | Most controller connections each server keeps open; least recently used idle ones are closed beyond this (controller state such as cached auth is kept) | No | `256` |
> \| `UNIFI_FLEET_EXPECTED_REPLICAS` | How many processes (server replicas and workers) share each controller's rate limit. While Redis is unavailable each falls back to `UNIFI_RATE_LIMIT_REQUESTS` divided by this (at least 1), so together they stay near the controller's budget | No | `1` |
> \| `UNIFI_FLEET_FANOUT_CONCURRENCY` | Most controllers one `fleet_fanout` call reaches at once | No | `20` |
> \| `UNIFI_FLEET_FANOUT_TIMEOUT_SECONDS` | Default per-controller timeout of `fleet_fanout` | No | `15` |
> \| `UNIFI_FLEET_FANOUT_DEADLINE_SECONDS` | Default overall deadline of `fleet_fanout`; controllers not done by then are reported as timed out | No | `60` |
> \| `UNIFI_FLEET_WORKER_HEALTH_SECONDS` | Worker (`python -m src.worker`): how often each controller is health-checked | No | `60` |
> \| `UNIFI_FLEET_WORKER_SYNC_SECONDS` | Worker: how often each controller's sites and device inventory are refreshed | No | `900` |
> \| `UNIFI_FLEET_WORKER_CONCURRENCY` | Worker: most controller jobs one worker runs at once | No | `20` |
> \| `UNIFI_FLEET_WORKER_LEASE_SECONDS` | Worker: how long membership and controller leases last without renewal; a dead worker's controllers move to the others after this | No | `30` |
> \| `UNIFI_FLEET_WORKER_DOWN_AFTER` | Worker: consecutive failed health checks before a controller is `down` (earlier failures mark it `degraded`) | No | `3` |
> \| `MIGRATION_DATABASE_URL` | Used by `python -m src.fleet.cli migrate`/`status` instead of `DATABASE_URL`, so the schema owner can differ from the server's least-privilege role | No | unset |
> \| `UNIFI_STDIO_ROLE` | Role of the local stdio caller: `viewer`, `operator`, `admin` or `fleet-admin` | No | `fleet-admin` |
> \| `UNIFI_METRICS_ENABLED` | Enable Prometheus metrics server | No | `false` |
> \| `WEBHOOK_SECRET` | HMAC-SHA256 key verifying `X-UniFi-Signature` on `POST /webhooks/unifi`; without it the receiver refuses every request | To receive webhooks | unset |
> \| `UNIFI_CACHE_ENABLED` | Use the Redis read cache when Redis is configured; set `false` to bypass it | No | `true` |
> \| `REDIS_URL` | Redis URL for the response cache; takes precedence over `REDIS_HOST`/`PORT`/`DB`/`PASSWORD` | No | unset |
> \| `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` / `REDIS_PASSWORD` | Redis location for the response cache | No | `localhost` / `6379` / `0` / unset |
> \| `UNIFI_SITE_MANAGER_ENABLED` | Enable Site Manager API multi-site tools | No | `false` |
> \| `MCP_SERVER_TRANSPORT` | Transport: `stdio`, `http`, `sse`, `streamable_http` | No | `stdio` |
> \| `MCP_SERVER_HOST` | Server bind address for `http`/`sse`/`streamable_http` | No | `127.0.0.1` |
> \| `MCP_SERVER_PORT` | Server port (http/sse transports) | No | `3000` |
> \| `MCP_AUTH_MAX_FAILURES` | Failed bearer-token checks (`401`) allowed per client address within `MCP_AUTH_FAILURE_WINDOW_SECONDS` on a network transport. Past that the address gets `429` with `Retry-After` on every route except `/healthz` until the window clears. `0` disables it. Behind a reverse proxy every client shares the proxy's address unless uvicorn trusts its `X-Forwarded-For` (`FORWARDED_ALLOW_IPS`) | No | `10` |
> \| `MCP_AUTH_FAILURE_WINDOW_SECONDS` | Sliding window for `MCP_AUTH_MAX_FAILURES` | No | `60` |
> \| `MCP_STATELESS_HTTP` | Run the HTTP transports without MCP sessions so any replica can answer any request (ignored for stdio and SSE). `select_controller` is then refused: pass `controller=<name>` per call. The default (stateful) mode needs per-client sticky routing across replicas, keyed on something stable from `initialize` onward (see `MULTI_CONTROLLER.md`) | No | `false` |
> \| `MCP_AUTH_TOKEN` | Bearer token required to call the server over a network transport; comma-separate for several. Write an entry as `<role>:<token>` (`viewer`, `operator`, `admin`, `fleet-admin`) to give it a role; a bare token is `fleet-admin`. A misspelled or wrong-case role (`Viewer:`, `veiwer:`) stops the server instead of becoming a `fleet-admin` token. `http`/`sse`/`streamable_http` refuse to start without it. Ignored for `stdio` | No | unset |
> \| `DEBUG` | Register `debug_api_request`, an arbitrary GET or DELETE to any controller endpoint (destructive tier; never registered under `UNIFI_READ_ONLY`). Leave unset in production | No | unset |
> \| `LOG_LEVEL` | Logging level: `DEBUG`, `INFO`, `WARNING`, `ERROR` | No | `INFO` |
> \| `LOG_API_REQUESTS` | Log each controller API request | No | `true` |
> \| `UNIFI_CACHE_TTL` | Accepted but not used: read-cache lifetimes are fixed per resource type (sites, networks and WLANs 5 min, devices 1 min, clients 30 s) | No | `300` |
> \| `UNIFI_BACKUP_DOWNLOAD_DIR` | Directory `download_backup` writes into; the caller names only the file | No | `.` |
> \| `SUPERMEMORY_ENABLED` | Enable the Supermemory integration for operator notes | No | `false` |
> \| `SUPERMEMORY_API_KEY` | Supermemory API key | With `SUPERMEMORY_ENABLED` | unset |

* Required when `UNIFI_API_TYPE=local`.

### Configuration File

Alternatively, use a `config.yaml` file:

```yaml theme={null}
unifi:
  api_key: your-api-key-here
  api_type: cloud  # or 'local'
  host: api.ui.com  # or gateway IP for local
  port: 443
  verify_ssl: true
  site: default
  rate_limit: 100
  timeout: 30
  max_retries: 3

mcp:
  server_port: 3000
  log_level: INFO
```

**Note:** Environment variables take precedence over configuration file settings.

## Connecting to the cloud API and local gateways together

One server can talk to the UniFi cloud API, to a local gateway, or to several
of each at once. Three topologies are supported; which one you are in is
decided by whether `DATABASE_URL` is set and whether `UNIFI_MIXED_MODE` is on.

### 1. One controller, from the environment

No `DATABASE_URL`. `UNIFI_API_TYPE` picks the backend and the `UNIFI_*`
variables describe it; the registry holds a single controller named `default`.

```env theme={null}
# Cloud
UNIFI_API_TYPE=cloud-ea
UNIFI_API_KEY=<key>

# or one local gateway
UNIFI_API_TYPE=local
UNIFI_LOCAL_HOST=192.0.2.1
UNIFI_API_KEY=<key>
```

The API key lives in the environment. Tool modules follow `UNIFI_API_TYPE`:
`local` registers the cloud **and** local modules, a cloud type registers only
the cloud ones.

### 2. Several controllers, from Postgres (fleet mode)

Set `DATABASE_URL` and `UNIFI_FLEET_CREDENTIAL_KEY`, run
`python -m src.fleet.cli migrate`, then register each controller. Any mix of
local gateways and cloud controllers is allowed, each with **its own API key,
stored encrypted in the database** — not in the environment:

```python theme={null}
# A local gateway, with its own key
await mcp.call_tool("register_controller", {
    "name": "hq", "api_type": "local", "local_host": "192.0.2.1",
    "api_key": "<hq-key>", "labels": {"region": "west"}, "confirm": True,
})

# Another local gateway
await mcp.call_tool("register_controller", {
    "name": "branch-017", "api_type": "local", "local_host": "192.0.2.17",
    "api_key": "<branch-key>", "confirm": True,
})

# A cloud controller sharing one Site Manager key
await mcp.call_tool("register_cloud_account", {
    "name": "corp", "api_key": "<cloud-key>", "confirm": True,
})
await mcp.call_tool("register_controller", {
    "name": "cloud-east", "api_type": "cloud-ea",
    "cloud_account": "corp", "confirm": True,
})
```

Each key is stored in the `credentials` table as a Fernet ciphertext, encrypted
with `UNIFI_FLEET_CREDENTIAL_KEY`; `list_controllers` never returns a
credential, and `rotate_fleet_credentials` re-encrypts every stored key under a
new one. Calls are routed per call with `controller=<name>`, per session with
`select_controller`, or to the registry default. See `MULTI_CONTROLLER.md`.

### 3. Mixed cloud and local, in one server

Fleet mode routes to controllers of any type, but the **tool modules** are
chosen once at start-up from the base `UNIFI_API_TYPE`. On a cloud-typed
server the local-only modules (firewall zones and policies, MAC tags,
switching, QoS, RADIUS, Protect, topology and the rest) are therefore never
registered, so a registered local gateway is routable while most of its tools
do not exist.

Set `UNIFI_MIXED_MODE=true` to register the union instead:

```env theme={null}
UNIFI_API_TYPE=cloud-ea          # the base stays cloud-shaped
UNIFI_MIXED_MODE=true            # expose cloud AND local tools
UNIFI_API_KEY=<cloud-key>
DATABASE_URL=postgresql://...    # the gateways come from the registry
UNIFI_FLEET_CREDENTIAL_KEY=<key>
```

With it:

* the cloud and local tool modules are both registered, so a call routed to a
  cloud controller reaches the cloud tools and a call routed to a local gateway
  reaches the local ones;
* no base `UNIFI_LOCAL_HOST` is needed — the gateways come from the registry;
* `UNIFI_PROFILE` still narrows the union, so `UNIFI_PROFILE=protect` in mixed
  mode exposes the Protect modules only;
* the start-up log names the mode and the module count, so the exposed surface
  is visible rather than inferred.

Mixed mode widens the **tool** surface only. MCP resources are not routed —
they take no `controller` argument and are built from the process-wide
settings — so they stay gated on the base `UNIFI_API_TYPE`: a cloud base
exposes no local resources even with mixed mode on. Read a specific gateway
with a tool and `controller=<name>` instead.

Mixed mode changes **which tools exist**, never which controller a call reaches
or what a role may do: routing, the risk tiers, `UNIFI_READ_ONLY`,
`UNIFI_DRY_RUN` and the audit trail are unchanged.

### Limitations

* **A tool must be registered to be reachable.** A module outside the
  registered set cannot be called on any controller, whatever its type. This is
  what `UNIFI_MIXED_MODE` exists to fix.
* **MCP resources always read the default controller.** The `sites://` family
  and friends take no `controller` argument; use the tools for a specific
  controller. Because they are built from the process-wide settings and cannot
  be routed, they stay gated on the base `UNIFI_API_TYPE` — mixed mode does not
  expose local resources on a cloud base.
* **Tool modules are process-wide.** A cloud-only call routed to a cloud
  controller still fails on a local-only endpoint, and the reverse, because the
  endpoint does not exist on that backend — mixed mode registers both sets, it
  does not make an endpoint appear on a controller that lacks it.
* **One API type per registered controller.** A controller entry is `local`,
  `cloud-ea` or `cloud-v1`; to reach the same hardware both ways, register it
  twice under different names.

## Performance Tracking

### Agnost.ai Integration (Optional)

The UniFi MCP Server includes optional performance tracking powered by [agnost.ai](https://agnost.ai) to monitor server performance, usage patterns, and identify optimization opportunities.

#### Features

* **Real-time Performance Metrics**: Track tool execution times, success rates, and error patterns
* **User Analytics**: Monitor which tools are most frequently used and by whom
* **Resource Access Patterns**: Understand how MCP resources are accessed
* **Error Tracking**: Automatic error capture with stack traces and context
* **Custom Event Tracking**: Track specific business events (device adoptions, configuration changes)
* **Dashboard**: Visual analytics dashboard for performance insights

#### Configuration

Performance tracking is disabled by default and can be enabled via environment variables:

```env theme={null}
# Enable performance tracking
AGNOST_ENABLED=true

# Agnost Organization ID (obtain from https://app.agnost.ai)
AGNOST_ORG_ID=your-organization-id-here

# Optional: Custom endpoint (defaults to https://api.agnost.ai)
AGNOST_ENDPOINT=https://api.agnost.ai

# Optional: Control what data is tracked
AGNOST_DISABLE_INPUT=false   # Set to true to disable input parameter tracking
AGNOST_DISABLE_OUTPUT=false  # Set to true to disable output/result tracking
```

#### Tracking Controls

The agnost integration provides granular control over what data is tracked:

**Input Tracking (`disable_input`):**

* When `false` (default): Tool input parameters are tracked
* When `true`: Input parameters are excluded from tracking
* Use this to prevent sensitive configuration data from being sent

**Output Tracking (`disable_output`):**

* When `false` (default): Tool results and outputs are tracked
* When `true`: Output data is excluded from tracking
* Use this to prevent sensitive response data from being sent

**Example - Privacy-Focused Configuration:**

```env theme={null}
AGNOST_ENABLED=true
AGNOST_ORG_ID=your-org-id
AGNOST_DISABLE_INPUT=true   # Don't track input parameters
AGNOST_DISABLE_OUTPUT=true  # Don't track output data
# This configuration tracks only tool names, execution times, and success/failure
```

#### Tracked Metrics

The following metrics are automatically tracked by agnost.ai:

**Tool Invocation Metrics:**

* Tool name
* Input parameters (if `disable_input=false`)
* Output results (if `disable_output=false`)
* Execution time (milliseconds)
* Success/failure status
* Error messages (if any)

**Resource Access Metrics:**

* Resource URI accessed
* Response data (if `disable_output=false`)
* Response size
* Access patterns

**Error Metrics:**

* Error type and message
* Stack trace and context
* Request metadata
* Frequency and patterns

**Performance Metrics:**

* Average execution time per tool
* Tool usage frequency
* Peak usage times
* Success/failure rates

#### Privacy & Security

**Data Protection:**

* Granular control via `disable_input` and `disable_output` flags
* Sensitive data (API keys, passwords) automatically masked in logs
* All data encrypted in transit (HTTPS)
* Opt-in only (disabled by default)

**Privacy Controls:**

* Disable input tracking to prevent parameter exposure
* Disable output tracking to prevent response data exposure
* Complete opt-out by setting `AGNOST_ENABLED=false`
* No personally identifiable information (PII) collected by default

**Compliance:**

* GDPR compliant data handling
* Configurable data retention policies via agnost.ai dashboard
* Transparent data collection (documented in this API guide)
* Data encrypted in transit and at rest

#### Example Configurations

**Full Tracking (Docker):**

```bash theme={null}
docker run -i \
  --name unifi-mcp \
  -e UNIFI_API_KEY=your-unifi-api-key \
  -e UNIFI_API_TYPE=cloud \
  -e AGNOST_ENABLED=true \
  -e AGNOST_ORG_ID=your-organization-id \
  ghcr.io/enuno/unifi-mcp-server:latest
```

**Privacy-Focused Tracking (Claude Desktop):**

```json theme={null}
{
  "mcpServers": {
    "unifi": {
      "command": "uv",
      "args": ["--directory", "/path/to/unifi-mcp-server", "run", "mcp", "run", "src/main.py"],
      "env": {
        "UNIFI_API_KEY": "your-api-key-here",
        "UNIFI_API_TYPE": "cloud",
        "AGNOST_ENABLED": "true",
        "AGNOST_ORG_ID": "your-org-id",
        "AGNOST_DISABLE_INPUT": "true",
        "AGNOST_DISABLE_OUTPUT": "true"
      }
    }
  }
}
```

**Metadata-Only Tracking:**

```env theme={null}
AGNOST_ENABLED=true
AGNOST_ORG_ID=your-org-id
AGNOST_DISABLE_INPUT=true
AGNOST_DISABLE_OUTPUT=true
# Tracks only: tool names, execution times, success/failure rates
```

#### Viewing Analytics

Once configured, visit your agnost.ai dashboard at [app.agnost.ai](https://app.agnost.ai) to view:

* **Real-time Performance Metrics**: Tool execution times and latency
* **Usage Trends**: Most frequently used tools and resources
* **Error Rates**: Failure patterns and error types
* **Performance Bottlenecks**: Slowest operations and optimization opportunities
* **Historical Data**: Trends over time for capacity planning

For more information, see the [agnost.ai FastMCP documentation](https://docs.agnost.ai/fastmcp).

### MCP Toolbox Dashboard

For a web-based analytics dashboard with visual metrics and debugging tools, use MCP Toolbox:

```bash theme={null}
# Start with Docker Compose
docker-compose up -d

# Access dashboard
open http://localhost:8080
```

MCP Toolbox provides:

* Real-time performance monitoring
* Visual analytics and graphs
* Error tracking and debugging
* Historical trend analysis
* Request/response inspection

See [MCP\_TOOLBOX.md](https://docs.hashgrid.net/MCP_TOOLBOX.md) for complete documentation.

***

## Authentication

### Obtaining Your API Key

The UniFi MCP Server uses the official UniFi Cloud API with API key authentication:

1. **Login** to [UniFi Site Manager](https://unifi.ui.com)
2. **Navigate** to Settings → Control Plane → Integrations
3. **Create** a new API Key by clicking "Create API Key"
4. **Save** the key immediately - it's only displayed once!
5. **Store** the key securely in environment variables or secret management

### Authentication Method

The server uses **stateless API key authentication** via the `X-API-Key` HTTP header:

```http theme={null}
GET /v1/sites HTTP/1.1
Host: api.ui.com
X-API-Key: your-api-key-here
Accept: application/json
```

No session management or cookie handling is required. Each request is independently authenticated.

### API Access Modes

**Cloud API (Recommended)**

* Base URL: `https://api.ui.com/v1/`
* Access cloud-hosted UniFi instances
* Requires internet connectivity
* SSL verification recommended

**Local Gateway Proxy**

* Base URL: `https://{gateway-ip}:{port}/proxy/network/integration`
* Access local UniFi gateway directly
* Works without internet
* May require SSL verification disabled for self-signed certificates

### Current Limitations

* **Read-Only Access**: The Early Access API is currently read-only. Write operations will be available in future API versions and will require manual key updates.
* **Rate Limiting**: See [Rate Limiting](#rate-limiting) section below

### Security Considerations

* **Never hardcode API keys** in your code
* Store API keys in environment variables or secure secret management systems (AWS Secrets Manager, HashiCorp Vault, etc.)
* Use HTTPS when connecting to the UniFi API (especially for cloud)
* Implement proper access controls for MCP server access
* **Rotate keys regularly** - API keys can be regenerated from unifi.ui.com
* Monitor API key usage for suspicious activity
* Treat API keys like passwords - they provide full access to your UniFi environment

## MCP Tools

Tools are executable functions that perform actions on the UniFi Controller.

### Tool Categories

* **Read-only Tools:** Read-only operations for querying network information
* **Mutating Tools:** Mutating operations with safety mechanisms (confirm + dry-run)

### Health Check

#### `health_check`

Verify the MCP server is running and accessible.

**Parameters:** None

**Returns:**
Server health status information.

**Example:**

```python theme={null}
result = await mcp.call_tool("health_check", {})
```

**Response:**

```json theme={null}
{
  "status": "healthy",
  "version": "0.2.5",
  "api_type": "cloud"
}
```

### Debug Passthrough

#### `debug_api_request`

Send a raw GET or DELETE to any controller endpoint. Registered only when `DEBUG` is `true`, `1` or `yes`, and never under `UNIFI_READ_ONLY`. Every call needs the `destructive` tier (`admin` or `fleet-admin`); `UNIFI_DRY_RUN` forces a preview.

**Parameters:**

* `endpoint` (string, required): controller-relative API path starting with a single `/`, e.g. `/proxy/network/api/s/default/stat/device`. Absolute (`https://...`) and scheme-relative (`//host/...`) URLs are refused, so the controller's API key is never sent to another host
* `method` (string, optional): `GET` (default) or `DELETE`; anything else is refused
* `confirm` (boolean, optional): must be `true` for a `DELETE` unless `dry_run` is set
* `dry_run` (boolean, optional): return the request instead of sending it

**Returns:** The raw JSON response, or `{"dry_run": true, "operation": "debug_api_request", "method": ..., "endpoint": ...}`.

**Example:**

```python theme={null}
result = await mcp.call_tool("debug_api_request", {"endpoint": "/proxy/network/api/s/default/stat/device"})
```

### Controller Selection

Every controller-bound tool accepts an optional `controller` argument naming the controller to run against. Without it, a call goes to the controller chosen with `select_controller` in the current MCP session, then to the server's default. A **mutating** tool may not fall back to the server default when more than one controller is registered: it needs `controller=` or a `select_controller` in the same session. Until the controller registry ships (see `docs/FLEET_SCALING_PLAN.md`), the server has one controller, `default`, built from the `UNIFI_*` settings. MCP resources (`sites://` and friends) always use the default controller.

#### `list_controllers`

List the controllers this server can reach: name, API type, host, default site and labels (never credentials), plus the default controller and this session's selection.

**Parameters:** None

#### `select_controller`

Choose the controller this session's later tool calls target. A call's own `controller` argument still takes precedence.

**Parameters:** `name` (string, required) — a name from `list_controllers`

#### `get_active_controller`

Show which controller a call without `controller` would target, and whether it comes from the session (`select_controller`) or the default.

**Parameters:** None

### Fleet Registry Administration

Available only when `DATABASE_URL` is set, and never in read-only mode. These tools manage the controller registry rather than a controller, so they take no `controller` argument. They are `fleet-admin` tier, need `confirm=true`, accept `dry_run`, and every call is audited as an `admin` event with API keys redacted. A change is routable on the server that made it immediately, on other servers at once when Redis is configured (`registry:changed` pub/sub), and otherwise within `UNIFI_FLEET_REGISTRY_REFRESH_SECONDS`.

#### `register_controller`

Register a controller. Give it its own `api_key` (stored encrypted), or name a `cloud_account` to share that account's key.

**Parameters:** `name` (required), `api_type` (`local`, `cloud-ea`, `cloud-v1`; required), `local_host` (required for `local`), `local_port`, `local_verify_ssl`, `cloud_api_url`, `default_site`, `api_key`, `cloud_account`, `labels`, `make_default`, `confirm`, `dry_run`

#### `update_controller`

Change a controller; only the fields passed change. `enabled=false` takes it out of routing without deleting it; `make_default=true` moves the default to it.

**Parameters:** `name` (required), `local_host`, `local_port`, `local_verify_ssl`, `cloud_api_url`, `default_site`, `labels`, `enabled`, `api_key`, `make_default`, `confirm`, `dry_run`

#### `register_cloud_account`

Register a cloud (Site Manager) API key that cloud controllers can share.

**Parameters:** `name` (required), `api_key` (required), `api_type` (default `cloud-ea`), `cloud_api_url`, `labels`, `confirm`, `dry_run`

#### `rotate_fleet_credentials`

Re-encrypt every stored API key under the newest `UNIFI_FLEET_CREDENTIAL_KEY` key. Put the new key first (`new,old`), run this, then remove the old key.

**Parameters:** `confirm`, `dry_run`

#### Access control

Every tool has a risk tier: `read`, `write`, `destructive` or `fleet-admin`. A caller's role grants tiers: `viewer` (read), `operator` (+ write), `admin` (+ destructive), `fleet-admin` (+ registry, token and audit tools). Destructive tools include deletes, restores, restarts, client blocking and firewall policy changes (`create_firewall_policy`, `update_firewall_policy`, `update_zbf_policy`, `create_firewall_rule`, `update_firewall_rule`, `assign_network_to_zone`, `unassign_network_from_zone`), so an `operator` cannot change which traffic the firewall allows. A token can be narrowed further by `allow_modules`/`deny_modules` (patterns over tool module names, e.g. `protect_*`) and `controller_labels` (a controller must carry all of them). A refused call fails with `Permission denied: <tool> needs the '<tier>' tier` and is recorded as a `denied` audit event. `MCP_AUTH_TOKEN` tokens and stdio are break-glass `fleet-admin` callers (stdio's role is set by `UNIFI_STDIO_ROLE`). An `MCP_AUTH_TOKEN` entry written `<role>:<token>`, for example `viewer:<token>`, gets that role instead, with or without the fleet registry, so a single host can issue a read-only token. Static tokens take no module or controller filters; use issued tokens for those.

#### `create_api_token`

Issue a bearer token; its value is returned only in this result (only a hash is stored). **Parameters:** `name`, `role` (required), `expires_in_days` (default 90; 0 never expires), `allow_modules`, `deny_modules`, `controller_labels`, `confirm`, `dry_run`

#### `list_api_tokens`

Token names, roles, filters and dates, never values. **Parameters:** None

#### `revoke_api_token` / `update_api_token_role`

Revoke a token, or change its role. Every server applies the change within 30 seconds. **Parameters:** `name` (and `role`), `confirm`, `dry_run`

#### `search_audit_log`

Search the Postgres audit trail, newest first, with payloads decrypted when the audit key is set. Each search is itself audited. **Parameters:** `since`, `until` (ISO 8601), `event_type`, `operation`, `principal`, `controller`, `result`, `limit` (max 1000), `cursor`

#### `verify_audit_chain`

Check every server's hash chain for edited, missing or reordered records, that each chain ends exactly at its `audit_chain_heads` entry (so records deleted from the end of a chain, a whole chain, or the whole table are reported, and so is a chain whose head was deleted), and that each row's searchable columns match its sealed record. Returns the chain heads to keep off the server. **Parameters:** None

#### `export_audit_log`

Export stored records (payloads still encrypted) as JSON lines, verifiable with `python -m src.utils.audit_verify`. **Parameters:** `since`, `until`, `limit` (max 1000)

**Operator CLI** (needs only `DATABASE_URL`, except `import`):

```bash theme={null}
python -m src.fleet.cli migrate              # create or upgrade the schema
python -m src.fleet.cli status               # exit 1 if the schema is behind
python -m src.fleet.cli import --name hq     # register the UNIFI_* controller
python -m src.fleet.cli tokens create --name first-admin --role fleet-admin
python -m src.fleet.cli tokens list
python -m src.fleet.cli tokens revoke --name first-admin
```

### Fleet-Wide Reads

Available when `DATABASE_URL` is set. All six are `read` tier, from the module `fleet` (so `deny_modules: ["fleet"]` removes them), take no `controller` argument, and cover only the controllers inside the caller's `controller_labels` scope. Naming a controller that is unknown, disabled or out of scope is refused without saying which.

`fleet_health`, `fleet_sites` and `fleet_inventory` answer from the worker's snapshots (`python -m src.worker`, see `MULTI_CONTROLLER.md`) with one database query and no controller calls; `fleet_compare_versions` and `fleet_compare_sites` also read those snapshots but return comparison summaries without per-entry age fields. Without a running worker, these snapshot tools return unknown health or no sites/devices as applicable.

#### `fleet_health`

Each enabled controller's last health check: `ok`, `degraded` (failing), `down` (`UNIFI_FLEET_WORKER_DOWN_AFTER` failures in a row) or `unknown` (never checked), the failure detail, `capabilities` (`network_version` on local controllers), and ages. An entry is `stale` when its last check is more than three health intervals old. Also returns a count per state.

**Network 11 / UniFi OS 6 platform compatibility.** A controller with a USG gateway anywhere in its synced device inventory carries a `platform_compat` field: `{"network_11_capable": false, "reason": "..."}`. USG support ends at Network 10.6, so that site has no Network 11 target at all (DEVELOPMENT\_PLAN.md §4 Phase 8, Fix 6). `platform_compat` is **absent** — not `null`, not `true` — on every other controller, including cloud controllers (the worker never inventories them) and any local controller the worker has not yet synced; absence means "no USG-gating signal found", not an affirmative "compatible" result. There is no corresponding field for the other platform break (a classic, self-hosted Network Application Server): the Integration API's `/v1/info` has no host-type field, so it cannot be auto-detected — label that controller instead (e.g. `deployment=classic-server`) and filter `fleet_health`/`fleet_sites` on the `labels` parameter. See `docs/NETWORK_11_COMPATIBILITY.md` for the full compatibility matrix.

```json theme={null}
{
  "controllers": [
    {
      "name": "branch-office",
      "api_type": "local",
      "health": "ok",
      "capabilities": {"network_version": "10.6.21"},
      "platform_compat": {
        "network_11_capable": false,
        "reason": "USG-gated: at least one USG gateway was seen in this controller's device inventory. ..."
      }
    }
  ],
  "counts": {"ok": 1},
  "total": 1
}
```

**Parameters:** `health` (`ok`, `degraded`, `down` or `unknown`), `labels` (controllers carrying all of these)

#### `fleet_sites`

Every controller's sites: controller, `site_id` (UUID), short name, display name, age.

**Parameters:** `controllers` (names), `labels`

#### `fleet_inventory`

Devices across the fleet (local controllers; the worker does not inventory cloud controllers): controller, site, id, MAC, name, model, firmware, state, age. Also the total match count and a count per model.

**Parameters:** `model` (substring, case-insensitive, e.g. `UDM`), `firmware_below` (numeric comparison, e.g. `4.1.0`; devices without a version are left out), `state` (e.g. `OFFLINE`), `controllers`, `labels`, `limit` (default 200, max 1000), `offset`

```json theme={null}
// fleet_inventory(model="UDM", firmware_below="4.1")
{
  "devices": [
    {"controller": "store-017", "site_id": "88f7af54-...", "id": "a1b2...", "mac": "aa:bb:cc:00:11:22",
     "name": "Gateway", "model": "UDM-Pro", "firmware_version": "4.0.9", "state": "ONLINE",
     "synced_seconds_ago": 412.7}
  ],
  "total": 1, "offset": 0, "by_model": {"UDM-Pro": 1}
}
```

#### `fleet_compare_versions`

Compare firmware versions for one model across controllers. Returns the set of versions seen, the newest version, per-controller counts and versions, and a `behind` flag for each controller that does not have the newest version. Devices with no recorded version are not considered behind. Each model and controller entry also reports the age of the oldest inventory snapshot it used and a `stale` flag when that age exceeds three health-check intervals.

**Parameters:** `model` (substring, case-insensitive), `controllers`, `labels`

```json theme={null}
// fleet_compare_versions(model="U7PRO")
{
  "models": {
    "U7PRO": {
      "versions": ["8.0.10", "7.6.1"],
      "newest": "8.0.10",
      "controllers": {
        "hq": {"count": 2, "versions": ["8.0.10", "7.6.1"], "behind": false, "synced_seconds_ago": 120, "stale": false},
        "branch": {"count": 1, "versions": ["7.6.1"], "behind": true, "synced_seconds_ago": 95, "stale": false}
      },
      "data_as_of_seconds_ago": 120,
      "stale": false
    }
  },
  "controllers_compared": ["branch", "hq"],
  "total_devices": 3
}
```

#### `fleet_compare_sites`

Compare site short names across controllers. Returns the sites common to every controller compared, the sites unique to each controller, and the full per-controller site lists. Controllers with no synced sites contribute nothing to the common set. Every site entry includes the age of its snapshot; the response also reports the oldest snapshot age per controller and overall, plus a top-level `stale` flag when the overall age exceeds three health-check intervals.

**Parameters:** `controllers`, `labels`

```json theme={null}
// fleet_compare_sites(controllers=["hq", "branch"])
{
  "common_sites": ["default"],
  "unique_sites": {"hq": [], "branch": []},
  "by_controller": {
    "hq": [{"site_id": "s-hq", "short_name": "default", "name": "Head office", "synced_seconds_ago": 120, "stale": false}],
    "branch": [{"site_id": "s-br", "short_name": "default", "name": "Branch", "synced_seconds_ago": 95, "stale": false}]
  },
  "controller_sync_age": {"hq": 120, "branch": 95},
  "data_as_of_seconds_ago": 120,
  "stale": false,
  "controllers_compared": ["branch", "hq"],
  "total_sites": 2
}
```

#### `fleet_fanout`

Run one `read`-tier controller tool live on many controllers: at most `UNIFI_FLEET_FANOUT_CONCURRENCY` at once, each with its own timeout, all under one deadline. Each controller gets its own result, so one unreachable controller never fails the call. Every per-controller call goes through the normal pipeline: the caller's role, module filters and controller scope apply to it, and cached reads are served from the read cache. Write, destructive and `fleet_*` tools are refused. Prefer the snapshot tools above when they cover the question.

**Parameters:** `tool` (required, e.g. `list_devices`), `arguments` (the tool's arguments except `controller`), `controllers`, `labels`, `timeout_seconds` (default `UNIFI_FLEET_FANOUT_TIMEOUT_SECONDS`), `deadline_seconds` (default `UNIFI_FLEET_FANOUT_DEADLINE_SECONDS`)

```json theme={null}
// fleet_fanout(tool="get_application_info", labels={"region": "west"})
{
  "tool": "get_application_info",
  "results": {
    "store-001": {"ok": true, "data": {"application_version": "9.4.19"}},
    "store-007": {"ok": false, "error": "timed out after 15s"}
  },
  "succeeded": 1, "failed": 1, "elapsed_seconds": 15.02
}
```

### Protect Phase 3 Tools

Protect tools are exposed in **local API mode** and can be isolated with `UNIFI_PROFILE=protect`.
The currently implemented Protect MCP resources are:

* `protect://nvrs`
* `protect://nvrs/{nvr_id}`
* `protect://cameras`
* `protect://cameras/{camera_id}`

The currently implemented Protect MCP tools are grouped below. `update_protect_device`,
`update_protect_light`, `update_protect_sensor`, `update_protect_chime`, `update_protect_viewer`,
`create_protect_live_view`, `update_protect_live_view`, and `send_protect_alarm_webhook` require
`confirm=True` and support `dry_run`.

#### Cameras

* `list_protect_cameras(limit=None, offset=None)`
* `get_protect_camera(camera_id)`

```python theme={null}
result = await mcp.call_tool("list_protect_cameras", {"limit": 10, "offset": 0})
camera = await mcp.call_tool("get_protect_camera", {"camera_id": "cam-1"})
```

#### NVRs

* `list_protect_nvrs()`
* `get_protect_nvr(nvr_id)`

```python theme={null}
result = await mcp.call_tool("list_protect_nvrs", {})
nvr = await mcp.call_tool("get_protect_nvr", {"nvr_id": "nvr-1"})
```

#### Devices, lights, sensors, and chimes

* `list_protect_devices(limit=None, offset=None)`
* `get_protect_device(device_id)`
* `update_protect_device(device_id, ...)`
* `list_protect_lights(limit=None, offset=None)`
* `get_protect_light(light_id)`
* `update_protect_light(light_id, ...)`
* `list_protect_sensors(limit=None, offset=None)`
* `get_protect_sensor(sensor_id)`
* `update_protect_sensor(sensor_id, ...)`
* `list_protect_chimes(limit=None, offset=None)`
* `get_protect_chime(chime_id)`
* `update_protect_chime(chime_id, ...)`
* `list_protect_device_updates(limit=None, offset=None)` for device subscription messages

```python theme={null}
device = await mcp.call_tool("get_protect_device", {"device_id": "device-1"})
updates = await mcp.call_tool("list_protect_device_updates", {})
```

#### Views and viewers

* `get_protect_meta_info()`
* `list_protect_viewers(limit=None, offset=None)`
* `get_protect_viewer(viewer_id)`
* `update_protect_viewer(viewer_id, ...)`
* `list_protect_live_views(limit=None, offset=None)`
* `get_protect_live_view(live_view_id)`
* `create_protect_live_view(live_view)`
* `update_protect_live_view(live_view_id, ...)`

```python theme={null}
meta = await mcp.call_tool("get_protect_meta_info", {})
viewers = await mcp.call_tool("list_protect_viewers", {})
```

#### Events and alarm webhooks

* `list_protect_events(limit=None, offset=None)`
* `get_protect_subscribe_events(limit=None, offset=None)` compatibility alias
* `get_protect_event_messages(limit=None, offset=None)` compatibility alias
* `get_protect_subscribe_devices(limit=None, offset=None)` compatibility alias
* `get_protect_device_updates(limit=None, offset=None)` compatibility alias
* `send_protect_alarm_webhook(webhook_id, payload=None)`: `webhook_id` is the alarm's webhook trigger ID, one path segment of letters, digits, `.`, `_`, `~`, `:` and `-`

```python theme={null}
events = await mcp.call_tool("list_protect_events", {})
webhook = await mcp.call_tool(
    "send_protect_alarm_webhook",
    {"webhook_id": "webhook-1", "payload": {"event": "manual-trigger"}, "confirm": True},
)
```

#### Still planned / not yet exposed

Snapshot capture, media stream helpers, talkback, and PTZ operations remain roadmap work.

### Device Management Tools

#### `get_device_details`

Get detailed information for a specific device.

**Parameters:**

* `site_id` (string, required): Site identifier
* `device_id` (string, required): Device integration-API UUID (the IDs returned
  by `get_network_topology`). A legacy `_id` ObjectId is also accepted.

**Returns:**
Object containing detailed device information, in the Integration v1 shape.

This tool reads the Integration v1 device route, which reports a different
shape from the legacy `/ea/` endpoints behind `search_devices` and
`list_devices_by_type`: `mac_address` rather than `mac`, `ip_address` rather
than `ip`, a string `state` (`"ONLINE"`) rather than the legacy integer, and no
`type` field at all. Keys the controller does not report are omitted rather
than returned as `null`, so look keys up defensively — an absent key means "not
reported", never "empty" or "false".

**Example:**

```python theme={null}
result = await mcp.call_tool("get_device_details", {
    "site_id": "default",
    "device_id": "b12257d4-d910-3b09-bcf9-e842229ac697"
})
```

**Response:**

```json theme={null}
{
  "id": "b12257d4-d910-3b09-bcf9-e842229ac697",
  "name": "Living Room AP",
  "model": "U7-Pro-Wall",
  "mac_address": "aa:bb:cc:dd:ee:ff",
  "ip_address": "192.168.2.100",
  "state": "ONLINE",
  "supported": true,
  "firmware_version": "8.6.11.18870",
  "firmware_updatable": false,
  "features": {"accessPoint": {}},
  "interfaces": {
    "ports": [],
    "radios": [{"frequencyGHz": 5, "channelWidthMHz": 320, "channel": 53}]
  }
}
```

#### `get_device_statistics`

Retrieve real-time statistics for a device.

**Parameters:**

* `site_id` (string, required): Site identifier
* `device_id` (string, required): Device ID

**Returns:**
Object containing device statistics.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_device_statistics", {
    "site_id": "default",
    "device_id": "507f1f77bcf86cd799439011"
})
```

**Response:**

```json theme={null}
{
  "device_id": "507f1f77bcf86cd799439011",
  "uptime": 86400,
  "cpu": 15,
  "mem": 42,
  "tx_bytes": 1024000000,
  "rx_bytes": 2048000000,
  "bytes": 3072000000,
  "state": 1,
  "uplink_depth": 0
}
```

#### `list_devices_by_type`

Filter devices by type (AP, switch, gateway).

**Parameters:**

* `site_id` (string, required): Site identifier
* `device_type` (string, required): Device type filter (uap, usw, ugw, udm, uxg, etc.)

**Returns:**
Array of device objects matching the type.

**Example:**

```python theme={null}
result = await mcp.call_tool("list_devices_by_type", {
    "site_id": "default",
    "device_type": "uap"
})
```

#### `search_devices`

Search devices by name, MAC, or IP address.

**Parameters:**

* `site_id` (string, required): Site identifier
* `query` (string, required): Search query string

**Returns:**
Array of matching device objects.

**Example:**

```python theme={null}
result = await mcp.call_tool("search_devices", {
    "site_id": "default",
    "query": "office"
})
```

#### `list_pending_devices`

List devices on a site that are waiting to be adopted.

**Parameters:**

* `site_id` (string, required): Site identifier
* `limit` (integer, optional): Maximum number of devices to return
* `offset` (integer, optional): Number of devices to skip

**Returns:**
Array of device objects in the legacy shape (`mac`, `ip`, integer `state`),
one per device the controller reports with `adopted: false`. The Integration
v1 API has no pending-devices route, so this reads the legacy `stat/device`
endpoint and filters it.

The controller assigns a device its `_id` when it adopts it, so **a pending
device has `id: null`**. Key a pending device by its `mac`, which is the
identifier `adopt_device` takes. Do not pass `id` to any device tool for a
pending record.

**Example:**

```python theme={null}
result = await mcp.call_tool("list_pending_devices", {
    "site_id": "default"
})
```

**Response:**

```json theme={null}
[
  {
    "id": null,
    "name": null,
    "model": "U7PG2",
    "type": "uap",
    "mac": "74:83:c2:aa:bb:cc",
    "ip": "192.168.2.57",
    "state": 1,
    "adopted": false,
    "version": "6.6.77.15402"
  }
]
```

Fields the controller does not report for an unadopted device come back as
`null`; the response above omits the remaining optional fields for brevity.

### Client Management Tools

#### `get_client_details`

Get detailed information for a specific client.

**Parameters:**

* `site_id` (string, required): Site identifier
* `client_mac` (string, required): Client MAC address

**Returns:**
Object containing detailed client information.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_client_details", {
    "site_id": "default",
    "client_mac": "aa:bb:cc:dd:ee:01"
})
```

**Response:**

```json theme={null}
{
  "mac": "aa:bb:cc:dd:ee:01",
  "hostname": "laptop-001",
  "ip": "192.168.2.100",
  "is_wired": false,
  "signal": -45,
  "tx_bytes": 1024000,
  "rx_bytes": 2048000
}
```

#### `get_client_statistics`

Retrieve bandwidth and connection statistics for a client.

**Parameters:**

* `site_id` (string, required): Site identifier
* `client_mac` (string, required): Client MAC address

**Returns:**
Object containing client statistics.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_client_statistics", {
    "site_id": "default",
    "client_mac": "aa:bb:cc:dd:ee:01"
})
```

**Response:**

```json theme={null}
{
  "mac": "aa:bb:cc:dd:ee:01",
  "tx_bytes": 1024000,
  "rx_bytes": 2048000,
  "tx_packets": 15000,
  "rx_packets": 20000,
  "tx_rate": 150000,
  "rx_rate": 200000,
  "signal": -45,
  "rssi": 55,
  "noise": -90,
  "uptime": 3600,
  "is_wired": false
}
```

#### `list_active_clients`

List currently connected clients.

**Parameters:**

* `site_id` (string, required): Site identifier

**Returns:**
Array of active client objects.

**Example:**

```python theme={null}
result = await mcp.call_tool("list_active_clients", {
    "site_id": "default"
})
```

#### `search_clients`

Search clients by MAC, IP, or hostname.

**Parameters:**

* `site_id` (string, required): Site identifier
* `query` (string, required): Search query string

**Returns:**
Array of matching client objects.

**Example:**

```python theme={null}
result = await mcp.call_tool("search_clients", {
    "site_id": "default",
    "query": "laptop"
})
```

### Network Information Tools

#### `get_network_details`

Get detailed network configuration.

**Parameters:**

* `site_id` (string, required): Site identifier
* `network_id` (string, required): Network ID

**Returns:**
Object containing network configuration.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_network_details", {
    "site_id": "default",
    "network_id": "507f191e810c19729de860ea"
})
```

**Response:**

```json theme={null}
{
  "id": "507f191e810c19729de860ea",
  "name": "LAN",
  "purpose": "corporate",
  "vlan_id": 1,
  "ip_subnet": "192.168.1.0/24",
  "dhcpd_enabled": true
}
```

#### `list_vlans`

List all VLANs in a site.

**Parameters:**

* `site_id` (string, required): Site identifier

**Returns:**
Array of VLAN/network objects.

**Example:**

```python theme={null}
result = await mcp.call_tool("list_vlans", {
    "site_id": "default"
})
```

#### `get_subnet_info`

Get subnet and DHCP information for a network.

**Parameters:**

* `site_id` (string, required): Site identifier
* `network_id` (string, required): Network ID

**Returns:**
Object containing subnet and DHCP configuration.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_subnet_info", {
    "site_id": "default",
    "network_id": "507f191e810c19729de860ea"
})
```

**Response:**

```json theme={null}
{
  "network_id": "507f191e810c19729de860ea",
  "name": "LAN",
  "ip_subnet": "192.168.1.0/24",
  "vlan_id": 1,
  "dhcpd_enabled": true,
  "dhcpd_start": "192.168.2.100",
  "dhcpd_stop": "192.168.1.254",
  "dhcpd_leasetime": 86400,
  "dhcpd_dns_1": "8.8.8.8",
  "dhcpd_dns_2": "8.8.4.4",
  "dhcpd_gateway": "192.168.2.1",
  "domain_name": "local"
}
```

#### `get_network_statistics`

Retrieve network usage statistics for a site.

**Parameters:**

* `site_id` (string, required): Site identifier

**Returns:**
Object containing network statistics across all networks.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_network_statistics", {
    "site_id": "default"
})
```

**Response:**

```json theme={null}
{
  "site_id": "default",
  "networks": [
    {
      "network_id": "507f191e810c19729de860ea",
      "name": "LAN",
      "vlan_id": 1,
      "client_count": 15,
      "total_tx_bytes": 10240000,
      "total_rx_bytes": 20480000,
      "total_bytes": 30720000
    }
  ]
}
```

### WAN and Dynamic DNS Tools

#### `list_wan_connections`

List WAN connections for a site.

**Parameters:**

* `site_id` (string, required): Site identifier

**Returns:**
Array of WAN connection objects.

Only `id` and `name` are guaranteed. The Integration v1
`/sites/{site_id}/wans` endpoint returns just those two fields and offers no
per-WAN detail route, so every other key — including `site_id`, `wan_type`,
`interface`, `status`, `dns_servers` and `is_backup` — may be missing. Fields
the controller does not report are omitted from the object rather than returned
as `null`, so consumers must look keys up defensively instead of assuming they
are always present. An absent key means "not reported", never "empty" or
"false".

**Example:**

```python theme={null}
result = await mcp.call_tool("list_wan_connections", {
    "site_id": "default"
})
# A sparse controller response:
# [{"id": "<uuid-1>", "name": "Internet 1"},
#  {"id": "<uuid-2>", "name": "Internet 2"}]
```

#### `list_dynamic_dns`

List Dynamic DNS records for a site. This local API tool redacts stored password
material and returns `password_configured` instead.

**Parameters:**

* `site_id` (string, required): Site identifier

**Returns:**
Array of Dynamic DNS records.

**Example:**

```python theme={null}
result = await mcp.call_tool("list_dynamic_dns", {
    "site_id": "default"
})
```

#### `get_dynamic_dns`

Get one Dynamic DNS record by ID.

**Parameters:**

* `dynamic_dns_id` (string, required): Dynamic DNS record ID
* `site_id` (string, required): Site identifier

**Returns:**
Dynamic DNS record details.

#### `create_dynamic_dns`

Create a Dynamic DNS record. Mutating operation; requires `confirm=true` unless
`dry_run=true`.

**Parameters:**

* `site_id` (string, required): Site identifier
* `host_name` (string, required): Hostname to update
* `service` (string, optional): Provider service name, default `custom`
* `interface` (string, optional): WAN interface identifier, default `wan`
* `login` (string, optional): Provider login or username
* `password` (string, optional): Provider password or token, sent as `x_password`
* `server` (string, optional): Provider server for custom configurations
* `custom_service` (string, optional): Custom provider protocol/service label
* `options` (array, optional): Provider-specific options
* `confirm` (boolean/string, required for execution): Set true to apply
* `dry_run` (boolean/string, optional): Preview without applying

**Example:**

```python theme={null}
result = await mcp.call_tool("create_dynamic_dns", {
    "site_id": "default",
    "host_name": "vpn.example.com",
    "service": "custom",
    "server": "updates.example.com",
    "custom_service": "dyndns",
    "login": "api-user",
    "password": "provider-token",
    "confirm": True
})
```

#### `update_dynamic_dns`

Update a Dynamic DNS record. Mutating operation; requires `confirm=true` unless
`dry_run=true`.

**Parameters:**

* `dynamic_dns_id` (string, required): Dynamic DNS record ID
* `site_id` (string, required): Site identifier
* `host_name`, `service`, `interface`, `login`, `password`, `server`,
  `custom_service`, `options` (optional): Fields to update
* `confirm` (boolean/string, required for execution): Set true to apply
* `dry_run` (boolean/string, optional): Preview without applying

#### `delete_dynamic_dns`

Delete a Dynamic DNS record. Mutating operation; requires `confirm=true` unless
`dry_run=true`.

**Parameters:**

* `dynamic_dns_id` (string, required): Dynamic DNS record ID
* `site_id` (string, required): Site identifier
* `confirm` (boolean/string, required for execution): Set true to apply
* `dry_run` (boolean/string, optional): Preview without applying

### Tagged MAC Tools

A tag is a named set of device MAC addresses (`member_table`). The controller
uses tags to target WiFi broadcasts at a subset of access points. These tools
read and write the local gateway's legacy `rest/tag` endpoint and require
`UNIFI_API_TYPE=local`; `list_device_tags` is the read-only Integration API view.
MACs are accepted with any common separator and normalized to lowercase colon
form. Write tools compare what the controller stored against the request and
attach `warnings` on any difference.

#### `list_mac_tags`

List MAC tags on a site.

**Parameters:**

* `site_id` (string, required): Site identifier

**Returns:**
Array of tags with `id`, `name`, `member_table` (device MACs) and `member_count`.

#### `get_mac_tag`

Get one MAC tag by ID.

**Parameters:**

* `tag_id` (string, required): Tag ID (24-hex ObjectId)
* `site_id` (string, required): Site identifier

#### `create_mac_tag`

Create a MAC tag. Mutating operation; requires `confirm=true` unless
`dry_run=true`. A name that matches an existing tag (case-insensitive) is
rejected instead of creating a duplicate.

**Parameters:**

* `name` (string, required): Tag display name
* `macs` (array, required): Device MAC addresses to assign (may be empty)
* `site_id` (string, required): Site identifier
* `confirm` (boolean/string, required for execution): Set true to apply
* `dry_run` (boolean/string, optional): Preview without applying

**Example:**

```python theme={null}
result = await mcp.call_tool("create_mac_tag", {
    "site_id": "default",
    "name": "Lobby APs",
    "macs": ["aa:bb:cc:dd:ee:01", "AA-BB-CC-DD-EE-02"],
    "confirm": True
})
```

#### `update_mac_tag`

Rename a MAC tag or change its members. Mutating operation; requires
`confirm=true` unless `dry_run=true`. `add_macs`/`remove_macs` are applied to
the tag's current members, so members added by someone else are kept. `macs`
replaces the member list outright (`[]` clears it) and cannot be combined with
`add_macs`/`remove_macs`. A dry run returns `changes` (`added_macs`,
`removed_macs`, `name`) and the `merged_payload` that would be sent.

**Parameters:**

* `tag_id` (string, required): Tag ID
* `site_id` (string, required): Site identifier
* `name` (string, optional): New display name
* `macs` (array, optional): Full replacement member list
* `add_macs` (array, optional): MACs to add
* `remove_macs` (array, optional): MACs to remove
* `confirm` (boolean/string, required for execution): Set true to apply
* `dry_run` (boolean/string, optional): Preview without applying

**Example:**

```python theme={null}
result = await mcp.call_tool("update_mac_tag", {
    "site_id": "default",
    "tag_id": "5f1e2d3c4b5a697886950413",
    "add_macs": ["aa:bb:cc:dd:ee:03"],
    "remove_macs": ["aa:bb:cc:dd:ee:01"],
    "dry_run": True
})
```

#### `delete_mac_tag`

Delete a MAC tag. Mutating operation; requires `confirm=true` unless
`dry_run=true`. A WLAN that broadcasts only on this tag's access points may
stop broadcasting.

**Parameters:**

* `tag_id` (string, required): Tag ID
* `site_id` (string, required): Site identifier
* `confirm` (boolean/string, required for execution): Set true to apply
* `dry_run` (boolean/string, optional): Preview without applying

### Device Migration Tools

Move a device to another site on this controller, or hand it to another
controller. Local API only (`UNIFI_API_TYPE=local`). Each tool finds the
device in `site_id` first (by MAC in any common format, or by 24-hex device
ID), so a typo fails before any command is sent and a dry run names the
device that would move. All three are mutating: they require `confirm=true`
unless `dry_run=true`, and are hidden in read-only mode.

#### `move_device_to_site`

Move an adopted device to another site on the same controller
(`cmd/sitemgr` `move-device`). The device reprovisions with the target
site's configuration, so expect a brief outage on it and anything behind it.

**Parameters:**

* `site_id` (string, required): Site the device is in now
* `device_id` (string, required): Device MAC or 24-hex device ID
* `target_site` (string, required): Destination site by short name
  (e.g. `default`), UI description, or 24-hex `_id`. A description shared by
  several sites is rejected as ambiguous.
* `confirm` (boolean/string, required for execution): Set true to apply
* `dry_run` (boolean/string, optional): Preview without applying

**Example:**

```python theme={null}
result = await mcp.call_tool("move_device_to_site", {
    "site_id": "default",
    "device_id": "aa:bb:cc:dd:ee:01",
    "target_site": "Branch Office",
    "dry_run": True
})
```

#### `migrate_device`

Migrate a device to another controller by setting its inform URL
(`cmd/devmgr` `migrate`). The device then appears on the new controller as
pending adoption; adopt it there to finish. An offline device only receives
the command when it next checks in, and the result carries a warning.

**Parameters:**

* `site_id` (string, required): Site the device is in now
* `device_id` (string, required): Device MAC or 24-hex device ID
* `inform_url` (string, required): `http(s)://<host>[:port]/inform`, e.g.
  `http://controller.example.net:8080/inform`. Other paths, credentials,
  query strings, and non-HTTP schemes are rejected.
* `confirm` (boolean/string, required for execution): Set true to apply
* `dry_run` (boolean/string, optional): Preview without applying

#### `cancel_device_migration`

Abort a pending migration (`cmd/devmgr` `cancel-migrate`). Only useful before
the device is adopted on the new controller.

**Parameters:**

* `site_id` (string, required): Site the device was migrated from
* `device_id` (string, required): Device MAC or 24-hex device ID
* `confirm` (boolean/string, required for execution): Set true to apply
* `dry_run` (boolean/string, optional): Preview without applying

### Site Management Tools

#### `get_site_details`

Get detailed site information.

**Parameters:**

* `site_id` (string, required): Site identifier

**Returns:**
Object containing site details.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_site_details", {
    "site_id": "default"
})
```

**Response:**

```json theme={null}
{
  "id": "default",
  "name": "Default Site",
  "desc": "Default site description"
}
```

#### `list_all_sites`

List all accessible sites.

**Parameters:** None

**Returns:**
Array of site objects.

**Example:**

```python theme={null}
result = await mcp.call_tool("list_all_sites", {})
```

#### `get_site_statistics`

Retrieve site-wide statistics.

**Parameters:**

* `site_id` (string, required): Site identifier

**Returns:**
Object containing comprehensive site statistics.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_site_statistics", {
    "site_id": "default"
})
```

**Response:**

```json theme={null}
{
  "site_id": "default",
  "devices": {
    "total": 25,
    "online": 24,
    "offline": 1,
    "access_points": 10,
    "switches": 8,
    "gateways": 7
  },
  "clients": {
    "total": 42,
    "wired": 15,
    "wireless": 27
  },
  "networks": {
    "total": 5
  },
  "bandwidth": {
    "total_tx_bytes": 102400000,
    "total_rx_bytes": 204800000,
    "total_bytes": 307200000
  }
}
```

### Site Manager API Tools (v0.3.0+)

**Note:** These tools require the Site Manager API to be enabled:

* Set `UNIFI_SITE_MANAGER_ENABLED=true`
* The same `UNIFI_API_KEY` is used for both Site Manager and Network APIs
* See [Site Manager API Docs](https://developer.ui.com/site-manager-api/gettingstarted) for more details

The Site Manager API (`api.ui.com/v1/`) provides cloud-based multi-site management, ISP metrics monitoring, SD-WAN configuration, and host management capabilities.

#### `get_isp_metrics`

Get ISP performance metrics for a specific site.

**Parameters:**

* `site_id` (string, required): Site identifier

**Returns:**
ISP metrics including bandwidth, latency, jitter, and packet loss.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_isp_metrics", {
    "site_id": "site-12345"
})
```

**Response:**

```json theme={null}
{
  "site_id": "site-12345",
  "isp_name": "Example ISP",
  "download_bandwidth_mbps": 500.0,
  "upload_bandwidth_mbps": 100.0,
  "latency_ms": 15.5,
  "jitter_ms": 2.1,
  "packet_loss_percent": 0.05,
  "timestamp": "2026-02-16T12:00:00Z"
}
```

#### `query_isp_metrics`

Query ISP metrics with optional filters for time range and site.

**Parameters:**

* `site_id` (string, optional): Site identifier (omit for all sites)
* `start_time` (string, optional): Start time in ISO format (e.g., "2026-02-01T00:00:00Z")
* `end_time` (string, optional): End time in ISO format

**Returns:**
List of ISP metrics matching the query.

**Example:**

```python theme={null}
result = await mcp.call_tool("query_isp_metrics", {
    "site_id": "site-12345",
    "start_time": "2026-02-01T00:00:00Z",
    "end_time": "2026-02-16T00:00:00Z"
})
```

#### `list_sdwan_configs`

List all SD-WAN configurations across your organization.

**Parameters:** None

**Returns:**
List of `{id, name, type}` summaries from `GET https://api.ui.com/v1/sd-wan-configs`. The v1.0.0 SD-WAN list has no pagination parameters. An empty list is different from a 404/401/403/error, which is not converted to empty success.

**Example:**

```python theme={null}
result = await mcp.call_tool("list_sdwan_configs", {})
```

**Response:**

```json theme={null}
[
  {"id": "9304163b-680d-4de8-a7a0-7617e328911d", "name": "SD-WAN test", "type": "sdwan-hbsp"}
]
```

#### `get_sdwan_config`

Get detailed SD-WAN configuration by ID.

**Parameters:**

* `config_id` (string, required): Configuration identifier

**Returns:**
Detailed upstream `data` object from `GET /v1/sd-wan-configs/{id}`: `id`, `name`, `type`, `variant`, `settings`, `hubs`, `spokes` and unrecognized fields are preserved. This is not a typed Site Manager destination/performance-policy resource. A null/malformed detail response is an error, not a fabricated config.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_sdwan_config", {
    "config_id": "sdwan-001"
})
```

#### `get_sdwan_config_status`

Get SD-WAN configuration deployment status.

**Parameters:**

* `config_id` (string, required): Configuration identifier

**Returns:**
The upstream `data` object from `GET /v1/sd-wan-configs/{id}/status`, including generation/apply states, per-hub/spoke warnings/errors, WAN state and connection/tunnel records. The documented `generateStatus` values are `OK`, `GENERATING`, `GENERATE_FAILED`; site `applyStatus` values are `ok`, `creating`, `updating`, `removing`, `createFailed`, `updateFailed`, `removeFailed`. Deployment/apply state is not a tunnel-health inference. Null/malformed known status fields are errors; unknown extension fields are preserved. This read-only Site Manager surface requires `UNIFI_SITE_MANAGER_ENABLED=true` and a valid cloud API key; it is registered with the `site_manager` module in full and `system` profiles across local/cloud modes, subject to RBAC and fleet routing. It is not a standalone controller endpoint.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_sdwan_config_status", {
    "config_id": "sdwan-001"
})
```

**Response:**

```json theme={null}
{
  "id": "",
  "fingerprint": "example-fingerprint",
  "generateStatus": "OK",
  "errors": [],
  "warnings": [],
  "hubs": [{"siteId": "site-hub", "applyStatus": "ok", "primaryWanStatus": {"latency": 1, "internetIssues": []}}],
  "spokes": [{"siteId": "site-spoke", "applyStatus": "ok", "connections": [{"hubId": "hub-1", "tunnels": [{"spokeWanId": "WAN", "hubWanId": "WAN", "status": "connected"}]}]}]
}
```

#### `list_hosts`

List all managed UniFi hosts/consoles in your organization.

**Parameters:**

* `limit` (integer, optional): Maximum number of hosts to return
* `offset` (integer, optional): Number of hosts to skip (for pagination)

**Returns:**
List of managed hosts with connection status and site counts.

**Example:**

```python theme={null}
result = await mcp.call_tool("list_hosts", {
    "limit": 50,
    "offset": 0
})
```

**Response:**

```json theme={null}
[
  {
    "host_id": "host-001",
    "hostname": "unifi-controller-1",
    "ip_address": "192.168.1.10",
    "mac_address": "00:11:22:33:44:55",
    "model": "UDM-Pro",
    "version": "3.2.9",
    "site_count": 3,
    "status": "online",
    "last_seen": "2026-02-16T12:00:00Z"
  }
]
```

#### `get_host`

Get detailed information about a specific managed host.

**Parameters:**

* `host_id` (string, required): Host identifier

**Returns:**
Detailed host information including model, version, and status.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_host", {
    "host_id": "host-001"
})
```

#### `get_version_control`

Get Site Manager API version control information.

**Parameters:** None

**Returns:**
Version information including current version, latest version, and deprecated versions.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_version_control", {})
```

**Response:**

```json theme={null}
{
  "current_version": "1.0.0",
  "latest_version": "1.1.0",
  "deprecated_versions": ["0.9.0", "0.8.0"],
  "changelog_url": "https://developer.ui.com/site-manager-api/changelog",
  "upgrade_recommended": true,
  "min_supported_version": "1.0.0"
}
```

#### Cloud Connector passthrough tools

`connector_network_get`, `connector_network_post`, `connector_network_put`, `connector_network_patch`, `connector_network_delete`, and the matching `connector_protect_*` tools forward a request to `https://api.ui.com/v1/connector/consoles/{console_id}/proxy/{network,protect}/{path}`. The `get` tools are `read` tier; the others need `confirm=True`.

**Parameters:** `console_id`, `path`, plus `params` (optional query parameters) on the `get` tools and `body`, **`confirm`** and **`dry_run`** on the mutating tools

**Input rules** (checked before any request is made; a bad value raises `ValidationError`):

* `console_id` is the host ID from `list_hosts`, for example `70A7...8EC3:1093004219`. Surrounding whitespace is stripped and the stripped value is what goes into the URL. What remains must be one path segment of letters, digits, `.`, `_`, `~`, `:` and `-`, and cannot be only dots.
* `path` is the sub-path under the proxy prefix, for example `api/s/default/stat/device`. Leading slashes are stripped. Every segment uses the same character set as `console_id` and cannot be empty, `.` or `..`; one trailing slash is allowed. The whole path is capped at 2048 characters. `?`, `#`, `%` and `\` are refused: on the `get` tools, pass query strings through `params`.

```python theme={null}
devices = await mcp.call_tool(
    "connector_network_get",
    {"console_id": "70A7...8EC3:1093004219", "path": "api/s/default/stat/device"},
)
```

## Phase 4: Mutating Tools

All Phase 4 tools modify UniFi configuration and require safety mechanisms.

### Safety Mechanisms

**All mutating tools implement these safety features:**

1. **Confirmation Required:** Must pass `confirm=True` to execute
2. **Dry Run Mode:** Pass `dry_run=True` to preview without changes
3. **Audit Logging:** All operations logged to `audit.log`
4. **Input Validation:** Parameters validated before execution

### Firewall Management

#### `list_firewall_rules`

List all firewall rules (read-only).

**Parameters:** `site_id`

#### `create_firewall_rule`

Create a new firewall rule.

**Parameters:** `site_id`, `name`, `action` (accept/drop/reject), `source`, `destination`, `protocol`, `port`, `enabled`, **`confirm`**, **`dry_run`**

**Example:**

```python theme={null}
# Dry run first
result = await mcp.call_tool("create_firewall_rule", {
    "site_id": "default",
    "name": "Block SSH",
    "action": "drop",
    "protocol": "tcp",
    "port": 22,
    "dry_run": True
})

# Then execute
result = await mcp.call_tool("create_firewall_rule", {
    "site_id": "default",
    "name": "Block SSH",
    "action": "drop",
    "protocol": "tcp",
    "port": 22,
    "confirm": True
})
```

#### `update_firewall_rule` & `delete_firewall_rule`

Modify or remove firewall rules. Requires `confirm=True`.

### Zone-Based Firewall (ZBF)

**Requires:** UniFi Network Application 9.0+, **Local API Only**

Zone-Based Firewall provides modern, scalable network security by grouping networks into security zones and defining policies between zones.

**⚠️ CRITICAL LIMITATIONS (Verified 2025-11-18):**

**API Availability:**

* ZBF is **NOT available via Cloud API** - requires local gateway API access (`api_type='local'`)
* Tested on UniFi Express 7 and UDM Pro (API v10.0.156)
* Only **2 out of 15 implemented endpoints actually exist** in the UniFi API

**What Works (✅):**

* `list_firewall_zones` - List all zones
* `create_firewall_zone` - Create new zone (untested but likely works)
* `update_firewall_zone` - Modify zone (untested but likely works)
* `delete_firewall_zone` - Delete zone (untested but likely works)
* `assign_network_to_zone` - Assign network to zone (untested but likely works)
* `unassign_network_from_zone` - Remove network from zone (untested but likely works)
* `get_zone_networks` - List networks in a zone

**What Doesn't Work (❌ Endpoints Do Not Exist):**

* ❌ Zone policy matrix operations (get/update/delete policies between zones)
* ❌ Application blocking per zone
* ❌ Zone traffic statistics
* **These tools have been REMOVED** - configure these features in UniFi Console UI

**Key Concepts (Available Features Only):**

* **Zones:** Logical groups of networks (e.g., Internal, External, Gateway, VPN, Hotspot, DMZ)
* **Network Assignment:** Assign networks to zones for organization
* **Zone-to-Zone Policies:** Must be configured manually in UniFi Console UI
* **Application Blocking:** Use DPI categories at network level instead

#### Zone Management

##### `list_firewall_zones`

List all configured firewall zones.

**Parameters:**

* `site_id` (string, required): Site identifier

**Returns:**
Array of zone objects with networks and metadata.

**Example:**

```python theme={null}
zones = await mcp.call_tool("list_firewall_zones", {
    "site_id": "default"
})
```

**Response:**

```json theme={null}
[
  {
    "id": "zone-lan",
    "site_id": "default",
    "name": "LAN Zone",
    "description": "Corporate network",
    "network_ids": ["net-001", "net-002"]
  },
  {
    "id": "zone-guest",
    "site_id": "default",
    "name": "Guest Zone",
    "description": "Guest WiFi",
    "network_ids": ["net-guest-001"]
  }
]
```

##### `create_firewall_zone`

Create a new firewall zone.

**Parameters:**

* `site_id` (string, required): Site identifier
* `name` (string, required): Zone name
* `description` (string, optional): Zone description
* `network_ids` (array, optional): Network IDs to assign
* `confirm` (boolean, required): Must be `true`
* `dry_run` (boolean, optional): Preview changes

**Returns:**
Created zone object.

**Example:**

```python theme={null}
# Dry run first
preview = await mcp.call_tool("create_firewall_zone", {
    "site_id": "default",
    "name": "IoT Zone",
    "description": "Internet of Things devices",
    "network_ids": ["net-iot-001"],
    "confirm": True,
    "dry_run": True
})

# Execute
zone = await mcp.call_tool("create_firewall_zone", {
    "site_id": "default",
    "name": "IoT Zone",
    "description": "Internet of Things devices",
    "network_ids": ["net-iot-001"],
    "confirm": True
})
```

##### `update_firewall_zone`

Update an existing zone's name, description, or networks.

**Parameters:**

* `site_id` (string, required): Site identifier
* `firewall_zone_id` (string, required): Zone ID
* `name` (string, optional): New zone name
* `description` (string, optional): New description
* `network_ids` (array, optional): Updated network list
* `confirm` (boolean, required): Must be `true`
* `dry_run` (boolean, optional): Preview changes

**Returns:**
Updated zone object.

##### `delete_firewall_zone`

Delete a firewall zone.

**Parameters:**

* `site_id` (string, required): Site identifier
* `zone_id` (string, required): Zone ID to delete
* `confirm` (boolean, required): Must be `true`
* `dry_run` (boolean, optional): Preview changes

**Returns:**
Deletion confirmation.

**Example:**

```python theme={null}
result = await mcp.call_tool("delete_firewall_zone", {
    "site_id": "default",
    "zone_id": "zone-old",
    "confirm": True
})
```

##### `assign_network_to_zone`

Dynamically assign a network to a zone.

**Parameters:**

* `site_id` (string, required): Site identifier
* `zone_id` (string, required): Zone ID
* `network_id` (string, required): Network ID to assign
* `confirm` (boolean, required): Must be `true`
* `dry_run` (boolean, optional): Preview changes

**Returns:**
Network assignment confirmation.

**Example:**

```python theme={null}
result = await mcp.call_tool("assign_network_to_zone", {
    "site_id": "default",
    "zone_id": "zone-dmz",
    "network_id": "net-web-001",
    "confirm": True
})
```

##### `unassign_network_from_zone`

Remove a network from a zone.

**Parameters:**

* `site_id` (string, required): Site identifier
* `zone_id` (string, required): Zone ID
* `network_id` (string, required): Network ID to remove
* `confirm` (boolean, required): Must be `true`
* `dry_run` (boolean, optional): Preview changes

**Returns:**
Network unassignment confirmation.

##### `get_zone_networks`

List all networks assigned to a zone.

**Parameters:**

* `site_id` (string, required): Site identifier
* `zone_id` (string, required): Zone ID

**Returns:**
Array of network assignments with details.

#### ZBF Usage Example

```python theme={null}
# Create a Guest zone
guest_zone = await mcp.call_tool("create_firewall_zone", {
    "site_id": "default",
    "name": "Guest",
    "description": "Guest WiFi network",
    "network_ids": ["net-guest"],
    "confirm": True
})

# Assign additional network to zone
await mcp.call_tool("assign_network_to_zone", {
    "site_id": "default",
    "zone_id": guest_zone["id"],
    "network_id": "net-guest-2",
    "confirm": True
})

# List all zones
zones = await mcp.call_tool("list_firewall_zones", {
    "site_id": "default"
})

# Get networks in a zone
networks = await mcp.call_tool("get_zone_networks", {
    "site_id": "default",
    "zone_id": guest_zone["id"]
})
```

**⚠️ Important Notes:**

* Zone-to-zone policies must be configured in UniFi Console UI
* Application blocking per zone is not available via API - use DPI categories at network level
* Zone traffic statistics are not available - use client statistics instead
* Always use `dry_run=True` to preview changes before applying
* See `tests/verification/PHASE2_FINDINGS.md` for complete verification details

### Network Configuration

#### `create_network`

Create a new network/VLAN.

**Parameters:** `site_id`, `name`, `vlan_id`, `subnet`, `purpose`, `dhcp_enabled`, DHCP settings, **`confirm`**, **`dry_run`**

#### `update_network` & `delete_network`

Modify or remove networks. Requires `confirm=True`.

#### `update_site_to_site_vpn`

Update a site-to-site VPN's `networkconf` entry. Requires `confirm=True`.

**Parameters:** `site_id`, `vpn_id`, `name`, `enabled`, `ipsec_peer_ip`, `remote_vpn_subnets`, `x_ipsec_pre_shared_key`, **`confirm`**, **`dry_run`**

`vpn_id` must be a 24-character hex ObjectId (or a UUID), as returned by `list_site_to_site_vpns`. It is lowercased before use, and anything else raises `ValidationError` before the controller is contacted. `get_site_to_site_vpn` filters the list client-side and does not apply this check.

### Device Control

#### `restart_device`

Restart a UniFi device.

**Parameters:** `site_id`, `device_mac`, **`confirm`**, **`dry_run`**

#### `locate_device`

Enable/disable LED locate mode.

**Parameters:** `site_id`, `device_mac`, `enabled`, **`confirm`**, **`dry_run`**

#### `upgrade_device`

Trigger firmware upgrade.

**Parameters:** `site_id`, `device_mac`, `firmware_url`, **`confirm`**, **`dry_run`**

### Client Management

#### `block_client` & `unblock_client`

Block or unblock a client from the network.

**Parameters:** `site_id`, `client_mac`, **`confirm`**, **`dry_run`**

#### `reconnect_client`

Force a client to reconnect.

**Parameters:** `site_id`, `client_mac`, **`confirm`**, **`dry_run`**

### MCP Prompts

The server exposes eight parameterised UniFi operational workflows through
the standard MCP prompts interface. A client discovers them with
`prompts/list` and retrieves a rendered workflow brief with `prompts/get`.

| Prompt | Purpose |
| - | - |
| `network_health_check` | Read-only site health assessment across devices, clients, connectivity, WAN and events |
| `troubleshoot_client_connectivity` | Investigate one client across association, addressing, VLAN, DHCP/DNS, signal, roaming and reachability |
| `troubleshoot_wifi_performance` | Analyse RF, channel, utilisation, client-distribution and roaming evidence |
| `investigate_device` | Read-only investigation of one device using inventory, status, topology, port and event evidence |
| `review_firewall_policy` | Read-only review of the firewall, zone, ACL and related configuration |
| `plan_network_change` | Produce a proposed change plan: scope, dependencies, risks, tool calls, dry run, validation, rollback, approval |
| `protect_health_check` | Read-only Protect health assessment from configuration and event metadata |
| `fleet_health_summary` | Summarise health across the authorised controllers, within a bounded investigation budget |

**Availability.** A workflow is advertised only when the tools it needs are
registered on this server, so the prompt list follows the API mode, the
profile and the read-only and dry-run settings. The full catalog, with each
prompt's arguments and the tools it depends on, is in
[docs/PROMPTS.md](https://docs.hashgrid.net/docs/PROMPTS.md).

**Retrieving a prompt is not running it.** `prompts/get` returns text: a
scoped brief telling an agent which read tools to call and what to report.
The agent then calls the tools itself, and the server's role check, the
`confirm` gate and the audit trail all still apply. Listing and retrieving
a prompt contacts no controller, runs no tool, reads no secret and starts
no work.

## A2A Endpoints

On the network transports the server also serves an A2A-style discovery and
delegation surface, behind the same bearer token as `/mcp`.

| Route | Method | Returns |
| - | - | - |
| `/a2a/agent-card` | GET | The agent card, built from the live tool registry |
| `/.well-known/agent-card.json` | GET | The same card, at the path the A2A specification recommends |
| `/a2a/discover` | POST | The capability manifest: the card, the per-domain tool inventory, the tool counts |
| `/a2a/delegate` | POST | Runs a delegated tool call |
| `/a2a/confirm` | POST | Approves a held call and runs it |
| `/a2a/audit` | GET | The A2A audit trail. Requires the `admin` role |

The card follows the AgentCard object shape of the A2A specification
version 0.3.0. The server does not implement the A2A task protocol, so the
card declares `streaming: false` and `pushNotifications: false`. See
[A2A.md](https://docs.hashgrid.net/A2A.md) for the runbook and
[docs/AUDIT\_A2A\_SKILLS.md](docs/AUDIT_A2A_SKILLS.md) for what is and is not
implemented.

A delegated call runs the registered tool through its own wrapper, so the
role check, the `confirm` gate, the dry-run behaviour and the audit record
apply exactly as they do for an MCP client. The caller's permissions come
from the bearer token's role, never from the request body.

## A2A Error Handling

All mutating tools raise:

* `ConfirmationRequiredError`: If `confirm` parameter not True
* `ValidationError`: If parameters invalid
* `ResourceNotFoundError`: If resource not found
* `APIError`: If UniFi API returns error

## Phase 5: Enterprise scale and operational excellence

Phase 5 expands the platform with WiFi management, port forwarding, DPI analytics, caching, webhook support, and enterprise-scale controls.

### WiFi Network (SSID) Management

#### `list_wlans`

List all wireless networks (SSIDs) in a site (read-only).

**Parameters:**

* `site_id` (string, required): Site identifier
* `limit` (integer, optional): Maximum number of WLANs to return
* `offset` (integer, optional): Number of WLANs to skip

**Example:**

```python theme={null}
result = await mcp.call_tool("list_wlans", {
    "site_id": "default",
    "limit": 10
})
```

**Response:**

```json theme={null}
[
  {
    "_id": "507f1f77bcf86cd799439011",
    "name": "HomeWiFi",
    "security": "wpapsk",
    "enabled": true,
    "is_guest": false,
    "wpa_mode": "wpa2",
    "wpa_enc": "ccmp",
    "vlan": null,
    "hide_ssid": false
  }
]
```

#### `create_wlan`

Create a new wireless network/SSID.

**Parameters:**

* `site_id` (string, required): Site identifier
* `name` (string, required): SSID name
* `security` (string, required): Security type (open, wpapsk, wpaeap)
* `password` (string, optional): WiFi password (required for wpapsk)
* `enabled` (boolean, optional): Enable the WLAN immediately (default: true)
* `is_guest` (boolean, optional): Mark as guest network (default: false)
* `wpa_mode` (string, optional): WPA mode (wpa, wpa2, wpa3) (default: wpa2)
* `wpa_enc` (string, optional): WPA encryption (tkip, ccmp, ccmp-tkip) (default: ccmp)
* `vlan_id` (integer, optional): VLAN ID for network isolation (1-4094)
* `hide_ssid` (boolean, optional): Hide SSID from broadcast (default: false)
* **`confirm`** (boolean, required): Confirmation flag (must be True)
* **`dry_run`** (boolean, optional): Preview without creating

**Example:**

```python theme={null}
# Create guest WiFi with VLAN isolation
result = await mcp.call_tool("create_wlan", {
    "site_id": "default",
    "name": "Guest WiFi",
    "security": "wpapsk",
    "password": "securepass123",
    "is_guest": true,
    "wpa_mode": "wpa2",
    "vlan_id": 100,
    "confirm": True
})
```

**Security Note:** Passwords are automatically masked in audit logs.

#### `update_wlan`

Update an existing wireless network.

**Parameters:**

* `site_id` (string, required): Site identifier
* `wlan_id` (string, required): WLAN ID
* `name` (string, optional): New SSID name
* `security` (string, optional): New security type
* `password` (string, optional): New WiFi password
* `enabled` (boolean, optional): Enable/disable the WLAN
* `is_guest` (boolean, optional): Mark as guest network
* `wpa_mode` (string, optional): New WPA mode
* `wpa_enc` (string, optional): New WPA encryption
* `vlan_id` (integer, optional): New VLAN ID
* `hide_ssid` (boolean, optional): Hide/show SSID
* **`confirm`** (boolean, required): Confirmation flag
* **`dry_run`** (boolean, optional): Preview without updating

#### `delete_wlan`

Delete a wireless network.

**Parameters:**

* `site_id` (string, required): Site identifier
* `wlan_id` (string, required): WLAN ID
* **`confirm`** (boolean, required): Confirmation flag
* **`dry_run`** (boolean, optional): Preview without deleting

#### `get_wlan_statistics`

Get WiFi usage statistics for a site or specific WLAN.

**Parameters:**

* `site_id` (string, required): Site identifier
* `wlan_id` (string, optional): Optional WLAN ID to filter statistics

**Example:**

```python theme={null}
result = await mcp.call_tool("get_wlan_statistics", {
    "site_id": "default"
})
```

**Response:**

```json theme={null}
{
  "site_id": "default",
  "wlans": [
    {
      "wlan_id": "507f1f77bcf86cd799439011",
      "name": "HomeWiFi",
      "enabled": true,
      "security": "wpapsk",
      "is_guest": false,
      "client_count": 12,
      "total_tx_bytes": 10240000000,
      "total_rx_bytes": 20480000000,
      "total_bytes": 30720000000
    }
  ]
}
```

### Port Forwarding Management

#### `list_port_forwards`

List all port forwarding rules in a site (read-only).

**Parameters:**

* `site_id` (string, required): Site identifier
* `limit` (integer, optional): Maximum number of rules to return
* `offset` (integer, optional): Number of rules to skip

**Example:**

```python theme={null}
result = await mcp.call_tool("list_port_forwards", {
    "site_id": "default"
})
```

**Response:**

```json theme={null}
[
  {
    "_id": "507f1f77bcf86cd799439011",
    "name": "Web Server",
    "dst_port": "80",
    "fwd": "192.168.2.100",
    "fwd_port": "8080",
    "proto": "tcp",
    "enabled": true,
    "log": false
  }
]
```

#### `create_port_forward`

Create a port forwarding rule.

**Parameters:**

* `site_id` (string, required): Site identifier
* `name` (string, required): Rule name/description
* `dst_port` (integer, required): Destination port (external/WAN port), 1-65535
* `fwd_ip` (string, required): Forward to IPv4 address (internal/LAN), dotted quad without leading zeros
* `fwd_port` (integer, required): Forward to port (internal)
* `protocol` (string, optional): Protocol (tcp, udp, tcp\_udp) (default: tcp\_udp)
* `src` (string, optional): Source restriction: `any`, an IPv4 address, or a CIDR network such as `203.0.113.0/24` (host bits are cleared, so `203.0.113.7/24` is sent as `203.0.113.0/24`) (default: any)
* `enabled` (boolean, optional): Enable the rule immediately (default: true)
* `log` (boolean, optional): Enable logging for this rule (default: false)
* **`confirm`** (boolean, required): Confirmation flag
* **`dry_run`** (boolean, optional): Preview without creating

**Example:**

```python theme={null}
# Forward external port 8080 to internal server
result = await mcp.call_tool("create_port_forward", {
    "site_id": "default",
    "name": "Web Server",
    "dst_port": 8080,
    "fwd_ip": "192.168.2.100",
    "fwd_port": 80,
    "protocol": "tcp",
    "confirm": True
})
```

#### `delete_port_forward`

Delete a port forwarding rule.

**Parameters:**

* `site_id` (string, required): Site identifier
* `rule_id` (string, required): Port forwarding rule ID
* **`confirm`** (boolean, required): Confirmation flag
* **`dry_run`** (boolean, optional): Preview without deleting

### Diagnostics and Historical Statistics

#### `get_speed_test_history`

Get historical speed test results for a site, oldest first. Reads the
`stat/report/archive.speedtest` report.

**Parameters:**

* `site_id` (string, required): Site identifier
* `hours` (integer, optional): How far back to look (default: 168 = 7 days)

**Returns:** `{"results": [...]}`. An empty history adds `statistics_note`
(see the note on the statistics reset below).

```json theme={null}
{
  "results": [
    {
      "id": "st-1",
      "download_speed_mbps": 935.0,
      "upload_speed_mbps": 918.0,
      "ping_ms": 12,
      "timestamp": "2026-10-22T03:00:00+00:00"
    }
  ]
}
```

#### `get_historical_stats`

Read archived time-series statistics from `stat/report/{interval}.{subject}`
— the only place historical airtime, per-client signal, and gateway counters
live.

**Parameters:**

* `site_id` (string, required): Site identifier
* `subject` (string, optional): `site`, `ap`, `user`, or `gw` (default: `ap`)
* `interval` (string, optional): `5minutes`, `hourly`, `daily`, or `monthly` (default: `hourly`)
* `hours` (integer, optional): Window ending now, 1-2160 (default: 24)
* `macs` (string or array, optional): Restrict to specific devices/clients
* `attrs` (array, optional): Report attributes; defaults per subject, `time` always included

**Returns:** `{"samples": [...]}`, oldest first, each sample carrying
epoch-ms `time`. An empty series adds `statistics_note` (below).

> **The statistics reset note.** UniFi Network 11 replaces the statistics
> engine and resets historical data, so an empty history immediately after
> the upgrade is expected — not evidence of an outage or a broken tool
> (DEVELOPMENT\_PLAN.md §4 Phase 8, N3). Every empty response from
> `get_historical_stats`, `get_speed_test_history`, `get_dpi_statistics`,
> `list_top_applications` and `get_client_dpi` carries a
> `statistics_note` field saying so. The field is absent on non-empty
> responses.

### DPI (Deep Packet Inspection) Statistics

#### `get_dpi_statistics`

Get Deep Packet Inspection statistics for a site.

**Parameters:**

* `site_id` (string, required): Site identifier

The counters are lifetime totals read from the `stat/sitedpi` report; the
endpoint accepts no time window. `app` and `cat` are the controller's
numeric catalog ids — translate them with `list_dpi_applications` and
`list_dpi_categories`. A controller that runs traffic identification
through the flow engine reports empty lists plus a `note` pointing at
`get_top_flows` / `get_flow_analytics`, and the `statistics_note` described
above.

**Example:**

```python theme={null}
result = await mcp.call_tool("get_dpi_statistics", {
    "site_id": "default"
})
```

**Response:**

```json theme={null}
{
  "site_id": "default",
  "applications": [
    {
      "app": 194,
      "cat": 4,
      "tx_bytes": 5120000000,
      "rx_bytes": 10240000000,
      "total_bytes": 15360000000
    }
  ],
  "categories": [
    {
      "cat": 4,
      "tx_bytes": 7680000000,
      "rx_bytes": 17920000000,
      "total_bytes": 25600000000
    }
  ],
  "total_applications": 15,
  "total_categories": 6
}
```

#### `list_top_applications`

List top applications by bandwidth usage.

**Parameters:**

* `site_id` (string, required): Site identifier
* `limit` (integer, optional): Number of top applications to return (default: 10)

**Example:**

```python theme={null}
result = await mcp.call_tool("list_top_applications", {
    "site_id": "default",
    "limit": 5
})
```

**Response:**

```json theme={null}
{
  "applications": [
    {
      "app": 194,
      "cat": 4,
      "tx_bytes": 5120000000,
      "rx_bytes": 10240000000,
      "total_bytes": 15360000000
    }
  ]
}
```

An empty list adds `statistics_note` (see the statistics reset note above).

#### `list_dpi_categories`

List the DPI categories the controller recognises.

**Parameters:** none.

**Returns:**
Array of category objects. Only `id` and `name` are guaranteed, and `id` is a
number on the Integration v1 route (categories are numbered from zero) while
the legacy endpoint sends a string. Fields the controller does not report are
omitted rather than returned as `null`.

**Example:**

```python theme={null}
result = await mcp.call_tool("list_dpi_categories", {})
# [{"id": 0, "name": "Instant messengers"}, {"id": 1, "name": "File transfer"}]
```

#### `list_dpi_applications`

List the DPI applications the controller recognises.

**Parameters:**

* `limit` (integer, optional): Maximum number of results
* `offset` (integer, optional): Starting position
* `filter_expr` (string, optional): Filter expression

**Returns:**
Array of application objects. Only `id` and `name` are guaranteed — the
Integration v1 route returns just those two, with a numeric `id` — so
`category_id`, `category_name`, `enabled`, `protocols` and `ports` may all be
missing. Absent keys are omitted rather than returned as `null`; an absent key
means "not reported", never "empty" or "disabled".

**Example:**

```python theme={null}
result = await mcp.call_tool("list_dpi_applications", {"limit": 2})
# [{"id": 3, "name": "ICQ"}, {"id": 7, "name": "Skype"}]
```

#### `get_client_dpi`

Get DPI statistics for a specific client.

**Parameters:**

* `site_id` (string, required): Site identifier
* `client_mac` (string, required): Client MAC address
* `time_range` (string, optional): Time range for statistics (default: 24h)
* `limit` (integer, optional): Maximum number of applications to return
* `offset` (integer, optional): Number of applications to skip

**Example:**

```python theme={null}
result = await mcp.call_tool("get_client_dpi", {
    "site_id": "default",
    "client_mac": "aa:bb:cc:dd:ee:01",
    "time_range": "24h"
})
```

**Response:**

```json theme={null}
{
  "site_id": "default",
  "client_mac": "aa:bb:cc:dd:ee:01",
  "time_range": "24h",
  "total_tx_bytes": 512000000,
  "total_rx_bytes": 1024000000,
  "total_bytes": 1536000000,
  "applications": [
    {
      "application": "Netflix",
      "category": "Streaming",
      "tx_bytes": 256000000,
      "rx_bytes": 768000000,
      "total_bytes": 1024000000,
      "percentage": 66.67
    }
  ],
  "total_applications": 8
}
```

A client with no DPI rows adds `statistics_note` (see the statistics reset
note above).

### Traffic Flow Monitoring

Real-time traffic flow monitoring and analysis for UniFi Network 9.0+. Provides comprehensive network visibility, security quick-response capabilities, and advanced analytics.

#### `get_traffic_flows`

Retrieve traffic flows with optional filtering.

By default the tool returns the 50 most-recent completed flows. Pass `hours` and/or
`max_flows` to page through a longer window instead (`pageNumber` / `pageSize` with
`timestampFrom` / `timestampTo` on the v2 endpoint); `source_mac` and `direction` are then also
sent as server-side pre-filters. The client-side filters apply in both modes.

**Parameters:**

* `site_id` (string, required): Site identifier
* `source_mac` (string, optional): Filter by source MAC address (case-insensitive)
* `source_ip` (string, optional): Filter by source IP address
* `source_zone_name` (string, optional): Filter by source firewall zone name
* `source_network_name` (string, optional): Filter by source VLAN display name
* `destination_ip` (string, optional): Filter by destination IP address
* `destination_port` (integer, optional): Filter by destination port
* `destination_zone_name` (string, optional): Filter by destination zone name (useful for `External`)
* `destination_network_name` (string, optional): Filter by destination VLAN display name
* `protocol` (string, optional): Filter by protocol (`TCP`, `UDP`, `ICMP`, ...)
* `action` (string, optional): Filter by firewall action (`allowed`, `blocked`)
* `direction` (string, optional): Filter by direction (`outgoing`, `incoming`, `local`)
* `risk` (string, optional): Filter by risk classification
* `min_bytes` (integer, optional): Only flows with at least this many total bytes
* `client_name_contains` (string, optional): Substring match against the source client name
* `limit` (integer, optional): Maximum number of flows to return (after filtering)
* `hours` (integer, optional): Page through the flows completed in the last `hours` (at least 1)
* `max_flows` (integer, optional): Upper bound on flows fetched in paged mode, before
  client-side filtering (1-50000, default 5000)

**Example:**

```python theme={null}
result = await mcp.call_tool("get_traffic_flows", {
    "site_id": "default",
    "source_mac": "aa:bb:cc:dd:ee:ff",
    "direction": "outgoing",
    "hours": 24
})
```

**Response:**

```json theme={null}
[
  {
    "flow_id": "flow_123abc",
    "site_id": "default",
    "source_ip": "192.168.2.100",
    "source_port": 50000,
    "destination_ip": "8.8.8.8",
    "destination_port": 443,
    "protocol": "tcp",
    "application_id": "app_https",
    "application_name": "HTTPS",
    "bytes_sent": 1024000,
    "bytes_received": 2048000,
    "packets_sent": 1000,
    "packets_received": 2000,
    "start_time": "2025-11-08T10:30:00Z",
    "end_time": null,
    "duration": null,
    "client_mac": "aa:bb:cc:dd:ee:ff",
    "device_id": "device_123"
  }
]
```

#### `stream_traffic_flows`

> ⚠️ **NOT SUPPORTED** — This tool raises `NotImplementedError`.
>
> The UniFi v2 `traffic-flows` endpoint returns a rolling snapshot of the 50 most-recent
> completed flows per call. It has no persistent connection, no pagination, and no server-push
> mechanism, so true streaming is not possible. Fast networks roll through the 50-flow window
> in under a second, making polling-based streaming impractical.
>
> **Use instead:** Poll `get_traffic_flows` on your own interval.

#### `get_connection_states`

> ⚠️ **NOT SUPPORTED** — This tool raises `NotImplementedError`.
>
> The UniFi v2 `traffic-flows` endpoint reports **completed** flows with start/end timestamps.
> It does not expose a live connection table or track in-progress connection states
> (`active`, `closed`, `timed_out`).
>
> **Use instead:** Use `get_traffic_flows` and inspect `flow_start_time` / `flow_end_time` fields
> to infer connection duration.

#### `get_client_flow_aggregation`

Get comprehensive traffic aggregation for a specific client.

**Parameters:**

* `site_id` (string, required): Site identifier
* `client_mac` (string, required): Client MAC address
* `hours` (integer, optional): Aggregate the flows of the last `hours` instead of the latest-50
  snapshot (see `get_traffic_flows`)
* `max_flows` (integer, optional): Upper bound on flows fetched in paged mode (1-50000, default 5000)

**Example:**

```python theme={null}
result = await mcp.call_tool("get_client_flow_aggregation", {
    "site_id": "default",
    "client_mac": "aa:bb:cc:dd:ee:ff",
    "hours": 24
})
```

**Response:**

```json theme={null}
{
  "client_mac": "aa:bb:cc:dd:ee:ff",
  "client_ip": "192.168.2.100",
  "site_id": "default",
  "total_flows": 1523,
  "total_bytes": 15360000000,
  "total_packets": 12000000,
  "active_flows": 45,
  "closed_flows": 1478,
  "auth_failures": 0,
  "top_applications": [
    {"application": "HTTPS", "bytes": 8589934592},
    {"application": "YouTube", "bytes": 4294967296}
  ],
  "top_destinations": [
    {"destination_ip": "8.8.8.8", "bytes": 2147483648}
  ]
}
```

#### `block_flow_source_ip`

Block source IP address from a suspicious traffic flow.

**Parameters:**

* `site_id` (string, required): Site identifier
* `flow_id` (string, required): Flow identifier to block
* `duration` (string, optional): Block duration - "permanent" or "temporary" (default: permanent)
* `expires_in_hours` (integer, optional): Hours until expiration (for temporary blocks)
* `confirm` (boolean, required): Confirmation flag (must be true)
* `dry_run` (boolean, optional): Validate without executing (default: false)

**Example:**

```python theme={null}
result = await mcp.call_tool("block_flow_source_ip", {
    "site_id": "default",
    "flow_id": "suspicious_flow_123",
    "duration": "temporary",
    "expires_in_hours": 24,
    "confirm": True
})
```

**Response:**

```json theme={null}
{
  "action_id": "action_abc123",
  "block_type": "source_ip",
  "blocked_target": "192.168.2.100",
  "rule_id": "rule_fw123",
  "duration": "temporary",
  "expires_at": "2025-11-09T10:30:00Z",
  "created_at": "2025-11-08T10:30:00Z"
}
```

#### `block_flow_destination_ip`

Block destination IP address from a traffic flow.

**Parameters:**

* `site_id` (string, required): Site identifier
* `flow_id` (string, required): Flow identifier to block
* `duration` (string, optional): Block duration (default: permanent)
* `expires_in_hours` (integer, optional): Hours until expiration
* `confirm` (boolean, required): Confirmation flag
* `dry_run` (boolean, optional): Validate without executing

**Example:**

```python theme={null}
result = await mcp.call_tool("block_flow_destination_ip", {
    "site_id": "default",
    "flow_id": "malicious_flow_456",
    "confirm": True
})
```

#### `block_flow_application`

Block application identified in a traffic flow using traditional firewall rules or DPI categories.

**Parameters:**

* `site_id` (string, required): Site identifier
* `flow_id` (string, required): Flow identifier to block
* `confirm` (boolean, required): Confirmation flag
* `dry_run` (boolean, optional): Validate without executing

**Example:**

```python theme={null}
result = await mcp.call_tool("block_flow_application", {
    "site_id": "default",
    "flow_id": "torrent_flow_789",
    "confirm": True
})
```

**Response:**

```json theme={null}
{
  "action_id": "action_def456",
  "block_type": "application",
  "blocked_target": "app_bittorrent",
  "rule_id": "rule_fw456",
  "duration": "permanent",
  "created_at": "2025-11-08T10:30:00Z"
}
```

**Note:** Uses traditional firewall rules or DPI categories. Zone-based application blocking is not available via API.

#### `export_traffic_flows`

Export traffic flows to JSON or CSV format.

**Parameters:**

* `site_id` (string, required): Site identifier
* `export_format` (string, optional): Export format - "json" or "csv" (default: json)
* `time_range` (string, optional): Time range (default: 24h)
* `include_fields` (array, optional): Specific fields to include (null = all fields)
* `filter_expression` (string, optional): Filter expression
* `max_records` (integer, optional): Maximum number of records

**Example:**

```python theme={null}
result = await mcp.call_tool("export_traffic_flows", {
    "site_id": "default",
    "export_format": "csv",
    "time_range": "24h",
    "include_fields": ["source_ip", "destination_ip", "bytes_sent", "application_name"],
    "max_records": 10000
})
```

**Response:**

```csv theme={null}
source_ip,destination_ip,bytes_sent,application_name
192.168.2.100,8.8.8.8,1024000,HTTPS
192.168.2.101,1.1.1.1,512000,DNS
...
```

#### `get_flow_analytics`

Get comprehensive flow analytics dashboard.

**Parameters:**

* `site_id` (string, required): Site identifier
* `time_range` (string, optional): Time range (default: 24h)

**Example:**

```python theme={null}
result = await mcp.call_tool("get_flow_analytics", {
    "site_id": "default",
    "time_range": "24h"
})
```

**Response:**

```json theme={null}
{
  "site_id": "default",
  "time_range": "24h",
  "statistics": {
    "total_flows": 15230,
    "total_bytes": 153600000000,
    "total_bytes_sent": 51200000000,
    "total_bytes_received": 102400000000
  },
  "protocol_distribution": {
    "tcp": 12000,
    "udp": 3000,
    "icmp": 230
  },
  "application_distribution": {
    "HTTPS": {"count": 5000, "bytes": 51539607552},
    "YouTube": {"count": 1500, "bytes": 25769803776}
  },
  "state_distribution": {
    "active": 450,
    "closed": 14500,
    "timed_out": 280
  },
  "total_flows": 15230,
  "total_states": 15230
}
```

#### `get_flow_statistics`

Get aggregated flow statistics.

**Parameters:**

* `site_id` (string, required): Site identifier
* `time_range` (string, optional): Time range (default: 24h)

**Example:**

```python theme={null}
result = await mcp.call_tool("get_flow_statistics", {
    "site_id": "default",
    "time_range": "7d"
})
```

#### `get_traffic_flow_details`

Get detailed information for a specific traffic flow.

**Parameters:**

* `site_id` (string, required): Site identifier
* `flow_id` (string, required): Flow identifier

**Example:**

```python theme={null}
result = await mcp.call_tool("get_traffic_flow_details", {
    "site_id": "default",
    "flow_id": "flow_123abc"
})
```

#### `get_top_flows`

Get top bandwidth-consuming flows.

**Parameters:**

* `site_id` (string, required): Site identifier
* `limit` (integer, optional): Number of top flows (default: 10)
* `time_range` (string, optional): Time range (default: 24h)
* `sort_by` (string, optional): Sort by field - "bytes", "packets", or "duration" (default: bytes)

**Example:**

```python theme={null}
result = await mcp.call_tool("get_top_flows", {
    "site_id": "default",
    "limit": 20,
    "sort_by": "bytes"
})
```

#### `get_flow_risks`

Get risk assessment for traffic flows.

**Parameters:**

* `site_id` (string, required): Site identifier
* `time_range` (string, optional): Time range (default: 24h)
* `min_risk_level` (string, optional): Minimum risk level - "low", "medium", "high", "critical"

**Example:**

```python theme={null}
result = await mcp.call_tool("get_flow_risks", {
    "site_id": "default",
    "min_risk_level": "high"
})
```

#### `get_flow_trends`

> ⚠️ **NOT SUPPORTED** — This tool raises `NotImplementedError`.
>
> The UniFi v2 `traffic-flows` endpoint provides a rolling snapshot of the 50 most-recent
> completed flows — it has no historical query capability and does not support time ranges
> or aggregation intervals.
>
> **Use instead:** For bandwidth time-series data, use the `stat/report/*` endpoints
> via other tools (e.g., client bandwidth history).

#### `filter_traffic_flows`

Filter flows with a comma-separated list of `key=value` pairs.

**Parameters:**

* `site_id` (string, required): Site identifier
* `filter_expression` (string, required): Comma-separated `key=value` pairs using the filter
  parameters of `get_traffic_flows` (e.g. `"protocol=TCP,destination_zone_name=External,min_bytes=1000"`)
* `limit` (integer, optional): Maximum number of flows
* `hours` (integer, optional): Page through the flows of the last `hours` (see `get_traffic_flows`)
* `max_flows` (integer, optional): Upper bound on flows fetched in paged mode (1-50000, default 5000)

**Example:**

```python theme={null}
result = await mcp.call_tool("filter_traffic_flows", {
    "site_id": "default",
    "filter_expression": "protocol=TCP,min_bytes=10000000",
    "hours": 6,
    "limit": 100
})
```

**Use Cases:**

* **Security Operations**: Real-time threat detection and automated blocking
* **Network Monitoring**: Live bandwidth and connection tracking
* **Compliance**: Flow data export for audit requirements
* **Troubleshooting**: Connection state analysis and client profiling
* **Analytics**: Application usage patterns and network insights

***

### Network Topology

Retrieve and visualize your complete network topology including devices, clients, and their interconnections.

**Available Tools:**

* `get_network_topology`: Retrieve complete topology graph
* `get_device_connections`: Device interconnection details
* `get_port_mappings`: Port-level connection mapping
* `export_topology`: Export topology in multiple formats
* `get_topology_statistics`: Topology summary statistics

#### Get Network Topology

Retrieve the complete network topology including all devices, clients, and their interconnections.

**Example:**

```python theme={null}
# Get complete topology
topology = await mcp.call_tool("get_network_topology", {
    "site_id": "default",
    "include_coordinates": True
})
```

**Response:**

```json theme={null}
{
  "site_id": "default",
  "site_name": "Default",
  "generated_at": "2025-01-24T12:00:00Z",
  "nodes": [
    {
      "node_id": "gateway_001",
      "node_type": "device",
      "name": "UDM Pro",
      "mac": "aa:bb:cc:dd:ee:01",
      "ip": "192.168.2.1",
      "model": "UDM-Pro",
      "type_detail": "ugw",
      "uplink_depth": 0,
      "state": 1,
      "adopted": true
    },
    {
      "node_id": "switch_001",
      "node_type": "device",
      "name": "USW-24-POE",
      "uplink_device_id": "gateway_001",
      "uplink_port": 1,
      "uplink_depth": 1
    }
  ],
  "connections": [
    {
      "connection_id": "conn_switch_001_uplink",
      "source_node_id": "switch_001",
      "target_node_id": "gateway_001",
      "connection_type": "uplink",
      "source_port": 1,
      "speed_mbps": 10000,
      "is_uplink": true,
      "status": "up"
    }
  ],
  "total_devices": 3,
  "total_clients": 12,
  "total_connections": 15,
  "max_depth": 3,
  "has_coordinates": true
}
```

#### Get Device Connections

Retrieve connection details for a specific device or all devices.

Both `get_device_connections` and `get_port_mappings` accept a topology
node id, a device MAC, or a device name as `device_id`. An identifier
that matches none of them raises `ResourceNotFoundError` — distinct
from a known device with no connections, which returns an empty
result. `get_port_mappings` reports the resolved node id as
`device_id` and echoes the caller's identifier as `requested_id`.

**Example:**

```python theme={null}
# Get connections for specific device
connections = await mcp.call_tool("get_device_connections", {
    "site_id": "default",
    "device_id": "switch_001"
})

# Get all device connections
all_connections = await mcp.call_tool("get_device_connections", {
    "site_id": "default",
    "device_id": None
})
```

#### Get Port Mappings

Get detailed port-level connection mapping for a device.

Each port maps to a **list** of peers: one physical port routinely
carries several hosts — a virtualization host bridging its guests, or a
downstream unmanaged switch.

**Example:**

```python theme={null}
port_map = await mcp.call_tool("get_port_mappings", {
    "site_id": "default",
    "device_id": "switch_001"
})
```

**Response:**

```json theme={null}
{
  "device_id": "switch_001",
  "ports": {
    "1": [
      {
        "connected_to": "gateway_001",
        "connected_name": "Gateway",
        "connection_type": "uplink",
        "speed_mbps": 10000,
        "status": "up"
      }
    ],
    "10": [
      {
        "connected_to": "client_002",
        "connected_name": "Hypervisor",
        "connection_type": "wired",
        "speed_mbps": 1000,
        "status": "up"
      },
      {
        "connected_to": "client_003",
        "connected_name": "VM guest",
        "connection_type": "wired",
        "speed_mbps": 1000,
        "status": "up"
      }
    ]
  }
}
```

#### Export Topology

Export network topology in multiple formats for visualization or documentation.

**Supported Formats:**

* `json`: JSON format for programmatic use
* `graphml`: GraphML (XML) format for network visualization tools (Gephi, yEd, etc.)
* `dot`: DOT format for Graphviz rendering

**Example:**

```python theme={null}
# Export as JSON
json_topology = await mcp.call_tool("export_topology", {
    "site_id": "default",
    "format": "json"
})

# Export as GraphML for visualization
graphml_topology = await mcp.call_tool("export_topology", {
    "site_id": "default",
    "format": "graphml"
})

# Export as DOT for Graphviz
dot_topology = await mcp.call_tool("export_topology", {
    "site_id": "default",
    "format": "dot"
})
```

**Example DOT Output:**

```dot theme={null}
digraph UniFiNetwork {
  node [shape=box];
  "gateway_001" [label="UDM Pro\n(device)"];
  "switch_001" [label="USW-24-POE\n(device)"];
  "ap_001" [label="AP Office\n(device)"];
  "switch_001" -> "gateway_001" [label="uplink"];
  "ap_001" -> "switch_001" [label="wired"];
}
```

#### Get Topology Statistics

Get summary statistics about your network topology.

**Example:**

```python theme={null}
stats = await mcp.call_tool("get_topology_statistics", {
    "site_id": "default"
})
```

**Response:**

```json theme={null}
{
  "site_id": "default",
  "total_devices": 3,
  "total_clients": 12,
  "total_connections": 15,
  "max_depth": 3,
  "generated_at": "2025-01-24T12:00:00Z"
}
```

**Use Cases:**

* **Network Documentation**: Auto-generate network diagrams
* **Visualization**: Export to Graphviz, yEd, or Gephi for interactive visualization
* **Capacity Planning**: Understand network depth and connection patterns
* **Troubleshooting**: Identify connection issues and network bottlenecks
* **Monitoring**: Track topology changes over time
* **Compliance**: Document network architecture for audits

***

### Caching (Optional)

The MCP server includes optional Redis-based caching to reduce API calls and improve performance.

**Configuration:**
Install the `redis` client with `pip install 'unifi-mcp-server[fleet]'`, then set these environment variables to enable caching:

* `REDIS_URL` (optional; e.g. `redis://:password@host:6379/0`, takes precedence over the fields below)
* `REDIS_HOST` (default: localhost)
* `REDIS_PORT` (default: 6379)
* `REDIS_DB` (default: 0)
* `REDIS_PASSWORD` (optional)

The cache is used only when `REDIS_URL` or `REDIS_HOST` is set explicitly (a local Redis that merely exists is never used) and `UNIFI_CACHE_ENABLED` is not `false`. It covers the inventory and configuration reads of the device, client, site, network and WLAN tools (statistics and RF-health tools are live metrics and never cached). Those tools gain an optional `fresh` argument that skips the cache and refreshes it. Any write to a controller, successful or not (dry runs excepted), clears that controller's cached reads; changes made outside this server show up when entries expire. Redis errors bypass the cache instead of failing a call. Hits and misses are counted in `unifi_mcp_cache_requests_total`.

Independently of Redis, every server keeps one long-lived connection per controller: tool calls share it, along with the controller's rate limiter (so `UNIFI_RATE_LIMIT_REQUESTS` applies across calls), its authentication probe and site map (reused for 5 minutes), and a circuit breaker that fails fast for 30 seconds after 5 consecutive calls the controller did not answer.

**Cache TTL by Resource Type:**

* Sites: 5 minutes
* Devices: 1 minute
* Clients: 30 seconds
* Networks: 5 minutes
* WLANs: 5 minutes
* Firewall Rules: 5 minutes
* Port Forwards: 5 minutes
* DPI Stats: 2 minutes

**Cache Invalidation:**
Caches are automatically invalidated when mutating operations are performed on related resources.

### Liveness (`GET /healthz`)

On a network transport the server answers `GET /healthz` with
`{"status": "ok"}` (`Cache-Control: no-store`) without a bearer token, for
load balancers and container health checks. It says only that the process is
serving HTTP: no version, controller or configuration. Use the `health_check`
tool or `/metrics` (both behind the token) for more.

### Webhooks (Optional)

On a network transport (`http`, `sse`, `streamable_http`) the server serves
`POST /webhooks/unifi` when `WEBHOOK_SECRET` is set, with the default handlers
below registered. Without the secret the route is not mounted at all; over
stdio there is no HTTP surface.

**Authentication:** the route does not use the MCP bearer token (UniFi cannot
send one). Each request must carry `X-UniFi-Signature`: the hex HMAC-SHA256 of
the raw request body, keyed with `WEBHOOK_SECRET`.

The receiver (`src/webhooks/`) is a Starlette endpoint and can also be mounted
on another app:

```python theme={null}
from src.webhooks import WebhookEventHandler, WebhookReceiver

receiver = WebhookReceiver(settings, app=starlette_app)  # POST /webhooks/unifi
WebhookEventHandler(settings).register_default_handlers(receiver)
```

An unconfigured receiver mounted this way refuses every request (`503`).

**Responses:** `200` processed (or `{"status": "duplicate"}` for a repeated
`event_id` within 5 minutes), `400` invalid JSON or event, `401` missing or
invalid signature, `429` more than 100 events per site per minute, `503` no
secret configured (receiver mounted by hand only).

**Supported Events:**

* `device.online` - Device came online
* `device.offline` - Device went offline
* `client.connected` - Client connected
* `client.disconnected` - Client disconnected
* `alert.raised` - Alert raised
* `event.occurred` - Generic event

**Event Handlers:**
`device.*` and `client.*` events clear the server's read cache (when Redis is
configured). A payload does not say which controller sent it, so the cached
reads of every controller are cleared. Custom handlers can be registered with
`receiver.register_handler(event_type, handler)`.

#### Redis webhook event bus

When Redis is configured (`REDIS_URL` or `REDIS_HOST`), every verified webhook
event is also published to the pub/sub channel `unifi:webhooks:v1:events` after
local handlers run. Other replicas and workers can subscribe to the channel to
react to events without each process receiving the HTTP callback directly.

**Payload shape:**

```json theme={null}
{
  "event_type": "device.online",
  "site_id": "default",
  "event_id": "evt-123456",
  "timestamp": "2026-10-27T12:34:56",
  "data": { "mac": "00:11:22:33:44:55", "name": "Access Point" },
  "origin": "<instance-id>"
}
```

A listener ignores messages whose `origin` matches its own instance id, so a
process does not handle an event twice.

The publish does not sit on the HTTP acknowledgement path. It is dispatched as
a background task once the local handlers have run, so a Redis that accepts
connections but stops answering cannot add the connection pool's socket
timeout to the webhook response, and a sender with a short delivery timeout
does not retry an event whose handlers already ran. At most 1000 publishes may
be in flight at once; past that the fan-out drops events with a warning rather
than queueing without bound, and local handling is unaffected. On shutdown the
server waits up to 5 seconds for outstanding publishes, then cancels them and
logs how many never reached Redis.

## MCP Resources

Resources provide read-only access to UniFi data through standardized URIs.

### Resource URI Scheme

Resources use a hierarchical URI scheme:

```
sites://<site_id>/<resource_type>/<identifier>
```

### Available Resources

#### `sites://{site_id}/devices`

List all devices in a site.

**Example:**

```python theme={null}
devices = await mcp.read_resource("sites://default/devices")
```

#### `sites://{site_id}/devices/{mac_address}`

Get a specific device by MAC address.

**Example:**

```python theme={null}
device = await mcp.read_resource("sites://default/devices/aa:bb:cc:dd:ee:ff")
```

#### `sites://{site_id}/networks`

List all networks in a site.

**Example:**

```python theme={null}
networks = await mcp.read_resource("sites://default/networks")
```

#### `sites://{site_id}/clients`

List all active clients in a site.

**Example:**

```python theme={null}
clients = await mcp.read_resource("sites://default/clients")
```

## Resource Error Handling

### Error Response Format

Errors are returned in a standardized format:

```json theme={null}
{
  "error": {
    "code": "AUTHENTICATION_FAILED",
    "message": "Invalid username or password",
    "details": {
      "controller": "controller.local",
      "timestamp": "2025-10-17T12:00:00Z"
    }
  }
}
```

### Common Error Codes

| Code | Description | Resolution |
| - | - | - |
| `AUTHENTICATION_FAILED` | Invalid credentials | Check username/password |
| `CONNECTION_ERROR` | Cannot connect to controller | Verify host/port and network connectivity |
| `DEVICE_NOT_FOUND` | Device doesn't exist | Verify MAC address |
| `INVALID_PARAMETER` | Invalid parameter value | Check parameter format |
| `PERMISSION_DENIED` | Insufficient permissions | Check user role |
| `RATE_LIMIT_EXCEEDED` | Too many requests | Slow down requests |
| `TIMEOUT` | Request timed out | Increase timeout or check network |

## Rate Limiting

### Official API Rate Limits

The UniFi Cloud API enforces the following rate limits:

| API Version | Requests per Minute | Notes |
| - | -: | - |
| **Early Access (EA)** | 100 | Current version |
| **v1 Stable** | 10,000 | Future release |

### Rate Limit Response

When rate limits are exceeded, the API returns:

**HTTP Status:** `429 Too Many Requests`

**Headers:**

```http theme={null}
Retry-After: 60
```

The `Retry-After` header indicates how many seconds to wait before retrying.

### MCP Server Rate Limit Handling

The MCP server implements intelligent rate limit handling:

* **Automatic retry** with exponential backoff
* **Request queuing** to prevent overwhelming the API
* **Configurable rate limit** via `UNIFI_RATE_LIMIT_REQUESTS` environment variable
* **Graceful degradation** when limits are reached

### Best Practices

**For Application Developers:**

* Batch operations when possible to reduce API calls
* Cache frequently accessed data (devices, networks, etc.)
* Avoid polling; use event-driven approaches when available
* Implement client-side rate limiting to stay under limits
* Monitor the `X-RateLimit-*` headers (if provided by API)

**For High-Volume Applications:**

* Consider the v1 Stable API when available (10,000 req/min)
* Distribute load across multiple time windows
* Use pagination efficiently to minimize requests
* Cache static data locally

**Rate Limit Configuration Example:**

```env theme={null}
# Set to match your API version
UNIFI_RATE_LIMIT_REQUESTS=100  # EA: 100, v1 Stable: 10000
```

## Examples

### Backup and Restore Operations

#### `trigger_backup`

Create a backup of the UniFi controller configuration.

**Parameters:**

* `site_id` (string, required): Site identifier
* `backup_type` (string, required): Type of backup - "network" or "system"
  * `"network"`: Network settings and device configurations only (\~5-10 MB)
  * `"system"`: Complete OS, application, and device configurations (\~50-100 MB)
* `retention_days` (integer, optional): Days to retain backup (default: 30, -1 for indefinite)
* `confirm` (boolean, required): Must be `true` to execute
* `dry_run` (boolean, optional): Validate without creating backup

**Returns:**

```json theme={null}
{
  "backup_id": "backup_20250129_153045",
  "filename": "autobackup_10.6.109_20251101_0630_1793514600049.unf",
  "download_url": "/proxy/network/dl/autobackup/autobackup_10.6.109_20251101_0630_1793514600049.unf",
  "backup_type": "network",
  "created_at": "2025-01-29T15:30:45Z",
  "retention_days": 30,
  "status": "completed"
}
```

**Note**: a UniFi OS console serves backup files from its `/dl/` tree —
automatic backups from `dl/autobackup/`, manual ones from `dl/backup/`.
See [Download Backup](https://docs.hashgrid.net/docs/UNIFI_API.md#download-backup).

**Example:**

```python theme={null}
backup = await mcp.call_tool("trigger_backup", {
    "site_id": "default",
    "backup_type": "network",
    "retention_days": 30,
    "confirm": True
})
print(f"Backup created: {backup['filename']}")
```

#### `list_backups`

List all available backups for a site.

**Parameters:**

* `site_id` (string, required): Site identifier

**Returns:**

```json theme={null}
[
  {
    "backup_id": "backup_20250129_120000",
    "filename": "backup_2025-01-29_12-00-00.unf",
    "backup_type": "NETWORK",
    "created_at": "2025-01-29T12:00:00Z",
    "size_bytes": 5242880,
    "version": "10.0.160",
    "is_valid": true,
    "cloud_synced": true
  }
]
```

#### `get_backup_details`

Get detailed information about a specific backup.

**Parameters:**

* `site_id` (string, required): Site identifier
* `backup_filename` (string, required): Backup filename

**Returns:** Detailed backup metadata dictionary

#### `download_backup`

Download a backup file to local storage.

**Parameters:**

* `site_id` (string, required): Site identifier
* `backup_filename` (string, required): Backup filename (a single path component; separators and `..` are rejected)
* `output_path` (string, required): Filename to save under. Only the filename is used — any directory component is ignored and the file is written inside `UNIFI_BACKUP_DOWNLOAD_DIR` (default: the current working directory)
* `verify_checksum` (boolean, optional): Calculate SHA-256 checksum (default: true)

**Returns:**

```json theme={null}
{
  "backup_filename": "backup_2025-01-29.unf",
  "local_path": "/home/user/unifi_backup.unf",
  "size_bytes": 5242880,
  "checksum": "a3f2b1...c4d5",
  "download_time": "2025-01-29T15:45:00Z"
}
```

**Example:**

```python theme={null}
result = await mcp.call_tool("download_backup", {
    "site_id": "default",
    "backup_filename": "backup_2025-01-29.unf",
    "output_path": "unifi_backup.unf",
    "verify_checksum": True
})
print(f"Downloaded: {result['size_bytes']} bytes")
print(f"Checksum: {result['checksum']}")
```

#### `delete_backup`

Delete a backup file from the controller (requires confirmation).

**Parameters:**

* `site_id` (string, required): Site identifier
* `backup_filename` (string, required): Backup filename
* `confirm` (boolean, required): Must be `true` to execute
* `dry_run` (boolean, optional): Validate without deleting

**Warning:** This permanently deletes the backup file.

#### `restore_backup`

Restore the UniFi controller from a backup file (DESTRUCTIVE operation).

**Parameters:**

* `site_id` (string, required): Site identifier
* `backup_filename` (string, required): Backup filename to restore from
* `create_pre_restore_backup` (boolean, optional): Create safety backup first (default: true, **recommended**)
* `confirm` (boolean, required): Must be `true` to execute
* `dry_run` (boolean, optional): Validate without restoring

**Returns:**

```json theme={null}
{
  "backup_filename": "backup_2025-01-29.unf",
  "status": "restore_initiated",
  "pre_restore_backup_id": "backup_20250129_160000_preRestore",
  "can_rollback": true,
  "restore_time": "2025-01-29T16:00:00Z",
  "warning": "Controller may restart. Devices may temporarily disconnect."
}
```

**Example:**

```python theme={null}
# ALWAYS use confirm=True and create_pre_restore_backup=True
result = await mcp.call_tool("restore_backup", {
    "site_id": "default",
    "backup_filename": "backup_2025-01-29.unf",
    "create_pre_restore_backup": True,  # Safety backup
    "confirm": True
})
print(f"Restore initiated")
print(f"Pre-restore backup: {result['pre_restore_backup_id']}")
```

**Critical Warnings:**

* Controller will restart during restore process
* All current configuration will be overwritten
* Devices may temporarily disconnect
* **ALWAYS** create pre-restore backup for rollback capability

#### `validate_backup`

Validate a backup file before restore.

**Parameters:**

* `site_id` (string, required): Site identifier
* `backup_filename` (string, required): Backup filename

**Returns:**

```json theme={null}
{
  "backup_id": "backup_20250129_120000",
  "backup_filename": "backup_2025-01-29.unf",
  "is_valid": true,
  "checksum_valid": true,
  "format_valid": true,
  "version_compatible": true,
  "backup_version": "10.0.160",
  "warnings": [],
  "errors": [],
  "size_bytes": 5242880,
  "validated_at": "2025-01-29T16:30:00Z"
}
```

**Example:**

```python theme={null}
validation = await mcp.call_tool("validate_backup", {
    "site_id": "default",
    "backup_filename": "backup_2025-01-29.unf"
})

if validation["is_valid"]:
    print("✅ Backup is valid and ready to restore")
else:
    print(f"❌ Validation errors: {validation['errors']}")
```

### Complete Example: Network Setup

```python theme={null}
import asyncio
from mcp import MCP

async def setup_guest_network():
    """Create a guest network with firewall isolation."""

    mcp = MCP("unifi-mcp-server")

    # Create guest network
    network = await mcp.call_tool("create_network", {
        "name": "Guest WiFi",
        "vlan_id": 20,
        "subnet": "192.168.20.0/24",
        "dhcp_enabled": true
    })

    # Create firewall rule to isolate guest network
    rule = await mcp.call_tool("create_firewall_rule", {
        "name": "Isolate Guest Network",
        "action": "drop",
        "source": "192.168.20.0/24",
        "destination": "192.168.1.0/24",
        "enabled": true
    })

    print(f"Created network: {network['name']}")
    print(f"Created firewall rule: {rule['name']}")

asyncio.run(setup_guest_network())
```

### Example: Monitor Devices

```python theme={null}
async def monitor_devices():
    """List all devices and their status."""

    mcp = MCP("unifi-mcp-server")

    devices = await mcp.call_tool("list_devices", {
        "site_id": "default"
    })

    for device in devices:
        status = "🟢" if device["status"] == "connected" else "🔴"
        print(f"{status} {device['name']} ({device['model']}) - {device['ip']}")

asyncio.run(monitor_devices())
```

## Example AI Assistant Prompts

This section provides example natural language prompts you can use with AI assistants (like Claude) that have access to the UniFi MCP Server. These demonstrate real-world use cases for the current feature set.

### Network Topology & Visualization

**Prompt:** "Show me a complete map of my network topology including all devices and how they're connected."

The assistant will use `get_network_topology` to retrieve the full network graph, showing devices, clients, and interconnections.

**Prompt:** "Export my network topology as a Graphviz DOT file so I can visualize it in a network diagram tool."

Uses `export_topology` with `format="dot"` to generate a DOT format file compatible with Graphviz and similar visualization tools.

**Prompt:** "Which devices are connected to my main switch on ports 5-10?"

The assistant will use `get_port_mappings` to show exactly what's plugged into specific switch ports.

### Quality of Service (QoS) Management

**Prompt:** "My video calls stutter when someone is uploading. Turn on Smart Queues on my WAN for my 500/50 Mbps connection."

The assistant will use `configure_smart_queue` with `download_mbps=500` and `upload_mbps=50` to enable fq\_codel shaping on the primary WAN.

**Prompt:** "Is Smart Queue enabled on my WAN, and what rates is it shaping to?"

Uses `get_smart_queue_status` to show whether the shaper is on and its download/upload rates in Mbps.

**Prompt:** "Which of my devices are routed through the VPN, and is the kill switch on?"

The assistant uses `list_traffic_routes` to read the site's Traffic Routes, including each route's matching target, target clients or networks, egress interface, and kill-switch setting.

### Backup & Disaster Recovery

**Prompt:** "Create a full system backup of my UniFi controller right now and keep it for 90 days."

The assistant will use `trigger_backup` with `backup_type="system"` and `retention_days=90`.

**Prompt:** "Show me all backups from the last 7 days and their sizes."

Uses `list_backups` and filters results to show recent backups with size information.

**Prompt:** "Download my latest network backup to /tmp/emergency-backup.unf and verify the checksum."

The assistant uses `download_backup` with checksum verification enabled for data integrity.

**Prompt:** "Set up automatic daily backups at 2 AM with 30-day retention."

Uses `configure_backup_schedule` to establish automated backup policies with cron expressions.

**Prompt:** "What's the status of my last backup? Is it complete and cloud-synced?"

The assistant uses `get_backup_status` to check backup completion and synchronization state.

### RADIUS & Authentication

**Prompt:** "Set up a RADIUS server for 802.1X authentication using my FreeRADIUS server at 192.168.1.50."

The assistant will use `create_radius_profile` to configure RADIUS authentication for network access control.

**Prompt:** "Create a RADIUS user account 'guest-wifi' with password for our guest portal."

Uses `create_radius_account` to provision user credentials for RADIUS authentication.

**Prompt:** "Configure the guest portal with a 4-hour session timeout and redirect users to our company website after login."

The assistant uses `configure_guest_portal` to customize the captive portal experience.

**Prompt:** "Show me all active RADIUS profiles and which ones have accounting enabled."

Uses `list_radius_profiles` to display authentication server configurations.

**Prompt:** "Create a hotspot package: '\$5 for 24 hours' for our café WiFi."

The assistant uses `create_hotspot_package` to set up paid WiFi access
sold by duration — the controller prices packages by the hour; bandwidth
and data quotas are not part of this API surface.

### Multi-Site Management

**Prompt:** "Compare the device count and health status across all my sites."

The assistant uses `aggregate_device_stats` to provide cross-site device analytics.

**Prompt:** "Create a new site called 'Branch Office' with description 'Remote office in Austin, TX'."

Uses `create_site` to provision a new UniFi site for multi-location management.

**Prompt:** "Move the device 'AP-Office-2' from the default site to the 'Branch Office' site."

The assistant uses `move_device_to_site` for cross-site device transfers.

**Prompt:** "Show me aggregate client statistics across all sites - total count, by connection type, and by band."

Uses `aggregate_client_stats` to provide organization-wide client analytics.

**Prompt:** "Set up a site-to-site VPN between my main office and branch office."

The assistant uses `configure_site_vpn` to establish secure inter-site connectivity.

### Firewall & Traffic Control

**Prompt:** "Block all BitTorrent traffic on my guest network during business hours."

The assistant will create a traffic matching list for P2P protocols and a firewall rule with schedule.

**Prompt:** "Create an ACL that allows only IT department devices (192.168.10.0/24) to access the server VLAN (192.168.20.0/24)."

Uses `create_acl` to implement network segmentation and access control.

**Prompt:** "Show me all traffic flows where clients are using more than 10 GB per day."

The assistant uses `list_traffic_flows` with filtering to identify high-bandwidth users.

**Prompt:** "Block the client at MAC address aa:bb:cc:dd:ee:ff for suspicious activity."

Uses `block_traffic_flow` to immediately block a specific device.

### Advanced Network Analysis

**Prompt:** "Analyze my network topology and tell me the maximum depth from the gateway and identify any bottlenecks."

The assistant uses `get_topology_statistics` to analyze network architecture and identify potential issues.

**Prompt:** "Show me all wired connections and their link speeds, highlighting any that aren't running at 1 Gbps."

Combines `get_device_connections` with filtering to identify underperforming network links.

**Prompt:** "Export all my site configurations for documentation purposes."

The assistant uses `export_site_config` to generate comprehensive site documentation.

### Troubleshooting & Monitoring

**Prompt:** "Why is my AP-Hallway showing as offline in the topology? Show me its last known connection details."

The assistant uses `get_network_topology` and filters for the specific device to diagnose connectivity issues.

**Prompt:** "Latency spikes whenever the link is busy. Is Smart Queue turned on, and are its rates set below my line speed?"

Uses `get_smart_queue_status` to check the WAN's shaper state and rates.

**Prompt:** "Which backup is most recent and has it been tested for restore capability?"

The assistant uses `list_backups` and `get_backup_details` to validate backup integrity.

**Prompt:** "Show me all RADIUS authentication failures in the last 24 hours."

Uses `list_radius_accounts` or authentication logs to troubleshoot access issues.

### Combining Multiple Operations

**Prompt:** "I'm setting up a new branch office. Create a site, configure basic networks, enable Smart Queues on the WAN, enable RADIUS authentication, and schedule daily backups."

The assistant will orchestrate multiple MCP tools:

1. `create_site` - Provision the site
2. `create_network` - Set up VLANs
3. `configure_smart_queue` - Enable WAN Smart Queues
4. `create_radius_profile` - Enable 802.1X authentication
5. `configure_backup_schedule` - Automate backups

**Prompt:** "Audit my network: show me topology depth, check Smart Queue status, count clients per site, check backup status, and verify all RADIUS servers are reachable."

Demonstrates complex multi-tool queries combining:

* `get_topology_statistics`
* `get_smart_queue_status`
* `aggregate_client_stats`
* `list_backups` + `get_backup_status`
* `list_radius_profiles`

### Best Practices for Prompts

1. **Be Specific:** Instead of "configure my network," say "create a guest WiFi network with 4-hour timeout"
2. **Include Context:** Mention site names, device IDs, IP ranges, or specific requirements
3. **Specify Timing:** Use "right now," "schedule for 2 AM," or "during business hours"
4. **Request Validation:** Ask for dry-run mode first: "Show me what would happen if..."
5. **Combine Related Tasks:** Group related operations in a single prompt for better workflow
6. **Ask for Summaries:** End with "and summarize the changes" to get a clear action report

## API Changelog

### Version 0.1.0 (Initial Release)

* Basic device management (list, get, restart)
* Network management (list, create)
* Client management (list, block)
* Firewall rule management (list, create)
* Site-based resource URIs

## Future Enhancements

Planned features for future releases:

* [x] Port forwarding management (✅ Phase 5)
* [x] WiFi network (SSID) management (✅ Phase 5)
* [x] DPI (Deep Packet Inspection) statistics (✅ Phase 5)
* [x] Webhook support for events (✅ Phase 5)
* [x] Redis caching for performance (✅ Phase 5)
* [x] Backup and restore operations (✅ v0.2.0 - Phase 4)
* [ ] Bulk operations for devices
* [ ] Advanced firewall rule management
* [ ] VPN configuration
* [ ] Alert and notification management

## Support

For issues, questions, or contributions:

* **GitHub Issues:** [enuno/unifi-mcp-server/issues](https://github.com/enuno/unifi-mcp-server/issues)
* **Documentation:** See `README.md` and `CONTRIBUTING.md`
* **Security Issues:** See `SECURITY.md`

***

**Last Updated:** 2026-06-20 | **API Version:** v10.3.55


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.