unifi-mcp-tool-builder — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited unifi-mcp-tool-builder (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
This skill guides you through adding new MCP tools to the existing UniFi MCP Server. Unlike building an MCP server from scratch, this focuses on extending the current 74-tool implementation with new UniFi Network Controller functionality while maintaining the project's quality standards.
Project Context:
Before implementation, clarify:
Tool Categories in UniFi MCP Server:
Load UniFi API reference:
# Read the comprehensive UniFi API documentation
Read: docs/UNIFI_API.mdThis document contains verified endpoints for UniFi Network v10.0.156+.
Critical considerations:
Study similar tools in the codebase:
# Example: For a new device tool, read existing device tools
Read: src/tools/devices.py
Read: src/tools/device_control.py
# For network tools:
Read: src/tools/networks.py
Read: src/tools/network_config.py
# For testing patterns:
Read: tests/unit/test_devices.pyKey patterns to identify:
CRITICAL: Not all documented API endpoints actually exist!
The UniFi MCP Server has discovered that many documented endpoints don't exist in real controllers. Before implementation:
docs/UNIFI_API.md verification notesKnown endpoint issues:
Document your plan covering:
Tool Definition:
{action}_{resource})Data Models:
Testing Strategy:
Documentation:
Location: src/models/{feature}.py
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class YourRequestModel(BaseModel):
"""Request model for your_tool operation.
Attributes:
site_id: UniFi site identifier
resource_id: Resource to operate on
options: Optional configuration parameters
"""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
site_id: str = Field(..., description="UniFi site ID (e.g., 'default')")
resource_id: str = Field(..., min_length=1, description="Resource identifier")
options: Optional[dict] = Field(None, description="Additional options")
class YourResponseModel(BaseModel):
"""Response from your_tool operation."""
success: bool
resource_id: str
message: strKey requirements:
model_config instead of Config)extra="forbid" to reject unknown fieldsLocation: src/tools/{category}.py (or a new module for a new feature area)
Tools are plain async functions. Every public async def in a module that src/main.py lists becomes a tool; there is no @mcp.tool() decorator. Each tool declares @read_tool or @write_tool (src/utils/tool_kind.py); registration fails without one, and UNIFI_READ_ONLY registers only read tools. The registry passes settings and hides it from the tool's public signature. Name helpers with a leading _ so they are not registered.
A read tool:
from typing import Any
from ..api.client import UniFiClient
from ..config import Settings
from ..utils import ValidationError, get_logger, sanitize_log_message, validate_site_id
from ..utils.tool_kind import read_tool
@read_tool
async def list_your_resources(site_id: str, settings: Settings) -> list[dict[str, Any]]:
"""List your resources on a site.
Args:
site_id: Site identifier (name or UUID)
settings: Application settings
Returns:
List of resource records
"""
site_id = validate_site_id(site_id)
logger = get_logger(__name__, settings.log_level)
async with UniFiClient(settings) as client:
if not client.is_authenticated:
await client.authenticate()
response = await client.get(f"/ea/sites/{site_id}/rest/your_resource")
data = response if isinstance(response, list) else response.get("data", [])
logger.info(sanitize_log_message(f"Listed {len(data)} resources for site {site_id}"))
return [YourModel(**item).model_dump() for item in data]A write tool:
from ..utils.tool_kind import write_tool
from ..utils.validators import coerce_bool, validate_confirmation
@write_tool
async def update_your_resource(
site_id: str,
resource_id: str,
settings: Settings,
name: str | None = None,
confirm: bool | str = False,
dry_run: bool | str = False,
) -> dict[str, Any]:
"""Update a resource.
Args:
site_id: Site identifier (name or UUID)
resource_id: Resource identifier
settings: Application settings
name: New name (unchanged when omitted)
confirm: Must be true to apply the change (required unless dry_run)
dry_run: Preview the change without applying it
Returns:
The updated record, or the planned change on a dry run
"""
validate_confirmation(confirm, "update your resource", dry_run)
site_id = validate_site_id(site_id)
payload: dict[str, Any] = {}
if name is not None:
payload["name"] = name
if not payload:
raise ValidationError("No updates provided")
if coerce_bool(dry_run):
return {"dry_run": True, "operation": "update_your_resource", "payload": payload}
async with UniFiClient(settings) as client:
if not client.is_authenticated:
await client.authenticate()
response = await client.put(
f"/ea/sites/{site_id}/rest/your_resource/{resource_id}", json_data=payload
)
return responseBuild paths with the Settings helpers (get_integration_path, get_protect_integration_path, get_v2_api_path) or the /ea/sites/{site_id}/... form that UniFiClient translates. Never hardcode /proxy/network/.... The tool-set skills (unifi-protect-tools, unifi-network-config-tools, unifi-security-tools, unifi-cloud-fleet-tools) list the paths and gotchas per area.
Implementation checklist:
async def with settings: Settings, declared @read_tool or @write_tool; helpers start with _src/utils/validators.pyconfirm and dry_run, validate_confirmation() first, dry run returned before any requestNonesrc/utils/exceptions.py (ValidationError for bad input); no broad except that hides the causeLocation: src/main.py
_LOCAL_TOOL_MODULES or _CLOUD_TOOL_MODULES, and add it to the UNIFI_PROFILE sets that should include it.UPDATE_TOOL_TIERS=1 pytest tests/unit/fleet/test_tiers.py. Destructive tools need a delete_, remove_ or bulk_delete_ prefix or an entry in DESTRUCTIVE_TOOLS (src/tool_registry.py). A write tool with a risky word in its name needs an entry in DESTRUCTIVE_TOOLS or RISKY_NAME_EXEMPTIONS.Run quality checks:
# Format code
black src/tools/your_file.py
isort src/tools/your_file.py
# Lint
ruff check src/tools/your_file.py --fix
# Type check
mypy src/tools/your_file.pyLocation: tests/unit/test_{category}.py
CRITICAL: UniFi MCP Server requires Test-Driven Development (TDD)
Location: tests/unit/tools/test_{module}_tools.py
from unittest.mock import AsyncMock, patch
import pytest
from src.config import Settings
from src.tools.your_category import update_your_resource
from src.utils.exceptions import ValidationError
@pytest.fixture
def settings(monkeypatch):
monkeypatch.setenv("UNIFI_API_KEY", "test-api-key")
monkeypatch.setenv("UNIFI_API_TYPE", "local")
monkeypatch.setenv("UNIFI_LOCAL_HOST", "192.168.2.1")
return Settings()
def _client(client_class, **responses):
client = AsyncMock()
client.is_authenticated = True
for method, value in responses.items():
setattr(client, method, AsyncMock(return_value=value))
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False) # a truthy value would swallow errors
client_class.return_value = client
return client
async def test_update_sends_only_changed_fields(settings):
with patch("src.tools.your_category.UniFiClient") as client_class:
client = _client(client_class, put={"data": [{"_id": "abc", "name": "new"}]})
await update_your_resource("default", "abc", settings, name="new", confirm=True)
assert client.put.await_args.kwargs["json_data"] == {"name": "new"}
async def test_update_refuses_without_confirm(settings):
with patch("src.tools.your_category.UniFiClient") as client_class:
with pytest.raises(ValidationError, match="confirm"):
await update_your_resource("default", "abc", settings, name="new")
client_class.assert_not_called()
async def test_update_dry_run_makes_no_request(settings):
with patch("src.tools.your_category.UniFiClient") as client_class:
result = await update_your_resource("default", "abc", settings, name="new", dry_run=True)
assert result["dry_run"] is True
client_class.assert_not_called()asyncio_mode = auto is set, so tests need no asyncio marker. --strict-markers rejects any marker except integration.
Test coverage requirements:
Target: 85%+ coverage for new code
# Run your specific test file
pytest tests/unit/test_your_category.py -v
# Run with coverage
pytest tests/unit/test_your_category.py --cov=src/tools/your_category --cov-report=term-missing
# Ensure coverage meets target (85%+)
pytest tests/unit/test_your_category.py --cov=src/tools/your_category --cov-report=html
open htmlcov/index.html # Review coverage reportLocation: API.md
Add your tool to the appropriate section with complete documentation:
### your_tool_name
**Description**: Brief one-line description
**Purpose**: Detailed explanation of when and why to use this tool
**Parameters**:
- `site_id` (string, required): UniFi site identifier (e.g., "default")
- `resource_id` (string, required): Resource identifier to operate on
- `options` (object, optional): Additional configuration options
- `confirm` (boolean, optional): Required for mutating operations (default: false)
- `dry_run` (boolean, optional): Preview changes without applying (default: false)
**Returns**:{ "success": true, "resource_id": "abc123", "message": "Successfully processed resource", "data": { / resource details / } }
**Dry-run response**:
{ "success": true, "dry_run": true, "preview": { "operation": "your_operation", "resource_id": "abc123", "changes": { / preview of changes / } }, "message": "Dry-run completed. Set confirm=True to apply changes." }
**Examples**:
result = await mcp.call_tool("your_tool_name", { "site_id": "default", "resource_id": "abc123", "dry_run": True })
result = await mcp.call_tool("your_tool_name", { "site_id": "default", "resource_id": "abc123", "confirm": True })
**Error Handling**:
- `ValidationError`: Invalid input, or confirm=True missing on a write
- `ResourceNotFoundError`: The resource does not exist (HTTP 404)
- `APIError`: UniFi API returned another error (check the message)
**API Endpoint**: `POST /api/s/{site}/rest/your_endpoint`
**Supported API Modes**:
- ✅ Local Gateway API (full support)
- ⚠️ Cloud V1 API (limited support)
- ❌ Cloud EA API (not supported)
If adding a new feature category, update README.md:
### Your New Feature Category
- **Feature Name**: Description of what it enables
- **Tools**: List of new tools
- **Use Cases**: Common scenarios where this is valuableAdd entry under "Unreleased" section:
## [Unreleased]
### Added
- New tool `your_tool_name` for managing XYZ (#123)
- Support for ABC feature in UniFi Network 10.0+# Run all tests
pytest tests/unit/
# Verify no regressions
pytest tests/unit/ --cov=src --cov-report=term-missing
# Check overall coverage (the CI gate is 80%)
pytest tests/unit/ --cov=src --cov-report=html# Format (auto-fix)
black src/ tests/
isort src/ tests/
# Lint (auto-fix where possible)
ruff check src/ tests/ --fix
# Type check
mypy src/
# Security scan
bandit -r src/
# Pre-commit hooks
pre-commit run --all-files# Start MCP Inspector
uv run mcp dev src/main.py
# Open http://localhost:5173
# Test your new tool with real inputs
# Verify responses match expectationsIf you have access to a UniFi controller:
Before submitting:
{action}_{resource})black formatting passesisort import sorting passesruff linting passesmypy type checking passesbandit security scan passespytest all tests passCopy a real tool rather than a sketch:
| Pattern | Copy from |
|---|---|
| Read tool, legacy API | list_wlans in src/tools/wifi.py |
| Read tool, integration API | list_acl_rules in src/tools/acls.py |
| Write tool with a partial update | update_protect_relay in src/tools/protect_relays.py |
| Read-modify-write update | update_port_profile in src/tools/port_profiles.py |
| Paginated list | list_protect_relays in src/tools/protect_relays.py (uses validate_limit_offset()) |
| Data across many controllers | fleet_fanout in src/fleet/fleet_tools.py (reads only) |
README.md - Project overview and quick startAPI.md - Complete MCP tool referenceAGENTS.md - AI agent development guidelinesDEVELOPMENT_PLAN.md - Roadmap and feature planningdocs/archive/TESTING_PLAN.md - Testing strategy (archived)CONTRIBUTING.md - Contribution guidelinesdocs/UNIFI_API.md - Verified endpoint documentation for v10.0.156+src/tools/devices.py - Device management examplessrc/tools/firewall.py - Firewall rule management patternssrc/tools/firewall_policies.py - Zone-Based Firewall policiessrc/tools/qos.py - QoS profile management examplestests/unit/tools/test_protect_relays.py - Write-gate and dry-run test examplesLast Updated: 2026-10-13 Maintained By: UniFi MCP Server Team
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.