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, 71 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 |
6 | 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.