unifi-protect-tools
Skill294 starsChanged 18 days agoApache-2.0View on GitHub
Use when adding, changing or testing a UniFi Protect MCP tool (any src/tools/protect_*.py module) - covers the Protect client and paths, v7 models, the confirm/dry_run write pattern, response shapes, test fixtures and known Protect API gaps.
We split this skill into the rules it gives an agent
6 rules. About 0 are common, 0 are distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 36
Every public async def in the module is a tool (register_module_tools() in src/tool_registry.py).
- Very specific: line 43
Use ProtectClient from src/api/protect_client.py, not UniFiClient.
- Very specific: line 45
Call await client.authenticate() first.
- Very specific: line 73
only then open ProtectClient
- Very specific: line 75
Do not write audit code; the registry wrapper audits every mutating call.
- Very specific: line 94
After you add or rename a tool, run UPDATE_TOOL_TIERS=1 pytest tests/unit/fleet/test_tiers.py.
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
UniFi Protect Tools
Protect tools talk to the Protect integration API on the local console. Read this before you add or change a tool in any src/tools/protect_*.py module. For the general add-a-tool workflow, see unifi-mcp-tool-builder.
Module map
15 modules, 72 functions, 71 registered tool names.
| Module | Tools | Examples |
src/tools/protect_alarm.py | 8 | list_protect_arm_profiles, set_current_protect_arm_profile, disable_protect_alarm |
src/tools/protect_alarm_hubs.py | 4 | list_protect_alarm_hubs, update_protect_alarm_hub |
src/tools/protect_bridges.py | 3 | get_protect_bridge, update_protect_bridge |
src/tools/protect_cameras.py | 2 | list_protect_cameras, get_protect_camera |
src/tools/protect_devices.py | 13 | list_protect_devices, update_protect_light, get_protect_chime |
src/tools/protect_events.py | 7 | list_protect_events, send_protect_alarm_webhook |
src/tools/protect_fobs.py | 3 | update_protect_fob |
src/tools/protect_link_stations.py | 3 | update_protect_link_station |
src/tools/protect_nvr.py | 2 | list_protect_nvrs, get_protect_nvr |
src/tools/protect_pos.py | 1 | ingest_pos_transaction |
src/tools/protect_relays.py | 4 | update_protect_relay, activate_protect_relay_output |
src/tools/protect_sirens.py | 6 | play_protect_siren, stop_protect_siren |
src/tools/protect_speakers.py | 4 | update_protect_speaker |
src/tools/protect_users.py | 4 | list_protect_users, list_protect_ulp_users |
src/tools/protect_views.py | 8 | get_protect_meta_info, create_protect_live_view |
MCP resources for Protect are in src/resources/protect.py. The raw connector_protect_* proxy tools are in src/tools/connector.py and belong to the cloud tool set.
Registration
- Every public
async defin the module is a tool (register_module_tools()insrc/tool_registry.py). Name helpers with a leading_. - Protect modules are in
_LOCAL_TOOL_MODULESonly (src/main.py). They do not exist in cloud modes. - A new module goes into three places in
src/main.py:_LOCAL_TOOL_MODULES, theprotectprofile and thesystemprofile. - Protect reads are not cached. Protect modules are not in
CACHED_MODULES(src/cache.py).
HTTP
- Use
ProtectClientfromsrc/api/protect_client.py, notUniFiClient. - Build every path with
settings.get_protect_integration_path("<endpoint>"). In local mode that gives/proxy/protect/integration/v1/<endpoint>. - Call
await client.authenticate()first; it checks access with a GET tocameras. - Send bodies with
json_data=, notjson=. - A status of 400 or more raises
APIError.
async with ProtectClient(settings) as client:
await client.authenticate()
response = await client.patch(
settings.get_protect_integration_path(f"relays/{relay_id}"),
json_data=payload,
)Models
- v7 models are in
src/models/protect_v7.pyand inherit_ProtectV7Base, which setspopulate_by_name=Trueandextra="allow". Unknown fields from newer firmware must not break parsing. - Fields are snake_case with a camelCase
alias=. Dump withmodel_dump(by_alias=True). - Older models (
src/models/protect_camera.py,src/models/protect_nvr.pyand others) do not allow extra fields. Do not copy them for new work. - Export new models from
src/models/__init__.py.
Write tools
Copy update_protect_relay in src/tools/protect_relays.py. The order is fixed:
validate_confirmation(confirm, "<operation>", dry_run)- validate IDs and arguments, and raise
ValidationErroron bad input - build the payload from the arguments that are not
None; raiseValidationErrorif it is empty if coerce_bool(dry_run): return {"dry_run": True, "operation": "<tool name>", ..., "payload": payload}- only then open
ProtectClient
Do not write audit code; the registry wrapper audits every mutating call. A tool with a risky word in its name (disable, reset and others) must be in DESTRUCTIVE_TOOLS (src/tool_registry.py) or in RISKY_NAME_EXEMPTIONS (tests/unit/fleet/test_tiers.py).
Response shapes
| Kind | Return |
| List | {"offset", "limit", "count", "totalCount", "data": [...]}; use validate_limit_offset() (default 100, range 1 to 1000) |
| Get or update | Model.model_validate(_extract_item(response)).model_dump(by_alias=True) |
| Action | {"success": True, <ids>, "response": ...} |
_extract_collection and _extract_item accept a bare value or a {"data": ...} wrapper. Each module has its own copy. Log through sanitize_log_message().
Tests
- Files:
tests/unit/tools/test_protect_<module>.py. Representative:tests/unit/tools/test_protect_relays.py. - Patch the client:
patch("src.tools.protect_relays.ProtectClient", return_value=mock_client), wheremock_clientis anAsyncMockwith__aenter__returning itself and__aexit__returningFalse. - Assert the exact path and body:
mock_client.patch.assert_awaited_once_with(path, json_data=...). - For every write, test refusal without
confirm(and withconfirm="false"), that the client is never created on refusal, and that a dry run makes no call. tests/unit/tools/test_protect_write_gates.pychecks every Protect write forconfirmanddry_run.- After you add or rename a tool, run
UPDATE_TOOL_TIERS=1 pytest tests/unit/fleet/test_tiers.py.
Gotchas
- The spec baseline is Protect v7.3.68 (
scripts/protect-api-spec-v7.3.68.json). Check an endpoint there before you call it. - The Phase 5a write paths are tested against mocks only; nobody has verified them on hardware. Say so when you change one.
get_protect_arm_profilecalls a GET path that is not in the v7.3.68 spec, so it may return 404.docs/UNIFI_API.mdstill calls Protect v7 "planned" near its start, which contradicts its later Phase 5a section.
Known defects (not fixed)
list_protect_device_updatesis defined once, insrc/tools/protect_devices.py.src/tools/protect_events.pyimports it for its two compatibility wrappers;register_module_toolsskips imported functions, so it registers once.TestToolModuleSetsintests/unit/test_main_runtime.pyfails if a tool name is defined in two modules.MUTATING_TOOLS_WITHOUT_GATEinsrc/tool_registry.pystill lists Protect tools that now have gates. It is harmless.
In this repository52 other files it gives its agents.
- AGENTS.mdAGENTS.md
- .agents/skills/fastmcp/CLAUDE.mdCLAUDE.md
- CLAUDE.mdCLAUDE.md
- .claude/skills/fastmcp/CLAUDE.mdCLAUDE.md
- .cursor/rules/common-mistakes.mdcCursor rule
- .cursor/rules/core-principles.mdcCursor rule
- .cursor/rules/environment-setup.mdcCursor rule
- .cursor/rules/mcp-tools.mdcCursor rule
- .cursor/rules/project-context.mdcCursor rule
- .cursor/rules/README.mdCursor rule
- .cursor/rules/unifi-api.mdcCursor rule
- .cursor/rules/workflow.mdcCursor rule
- FastMCP DevelopmentSkill
- mcp-builderSkill
- source-command-build-dockerSkill
- source-command-check-depsSkill
- source-command-cleanSkill
- source-command-docsSkill
- source-command-formatSkill
- source-command-lintSkill
- source-command-prSkill
- source-command-securitySkill
- source-command-setupSkill
- source-command-testSkill
- source-command-unifi-api-docs-update-2Skill
- source-command-unifi-api-docs-updateSkill
- source-command-unifi-mcp-add-toolSkill
- source-command-unifi-mcp-release-prepSkill
- source-command-unifi-mcp-test-coverageSkill
- source-command-unifi-mcp-update-docsSkill
- unifi-cloud-fleet-toolsSkill
- unifi-mcp-tool-builderSkill
- unifi-network-config-toolsSkill
- unifi-security-toolsSkill
- FastMCP DevelopmentSkill
- mcp-builderSkill
- unifi-cloud-fleet-toolsSkill
- unifi-mcp-tool-builderSkill
- unifi-network-config-toolsSkill
- unifi-protect-toolsSkill
- unifi-security-toolsSkill
- unifiSkill
- unifi-channel-planningSkill
- unifi-cloud-fleet-toolsSkill
- unifi-devicesSkill
- unifi-mcp-tool-builderSkill
- unifi-network-config-toolsSkill
- unifi-networkSkill
- unifi-protect-toolsSkill
- unifi-securitySkill
- unifi-security-toolsSkill
- unifi-systemSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

