This project is not:
- a general-purpose network management framework
- a UniFi controller replacement or admin dashboard
- a real-time monitoring or alerting service
- a CI/CD pipeline runner or deployment tool
- a web application with a frontend UI (dev consoles are debugging REPLs only)
- a database or persistent storage layer (all state lives on UniFi controllers)
- Tool functions MUST NOT contain business logic beyond argument validation and response formatting
- Tool functions MUST delegate to manager methods for all controller interactions
- Manager methods MUST NOT import from
<app>.tools(no circular dependencies) - All controller communication MUST flow through
ConnectionManager - Tool modules MUST import singletons from
<app>.runtime, never instantiate directly - Shared packages MUST NOT import from app packages (dependency flows downward only)
- App packages MUST NOT import from each other (no cross-app dependencies)
- All shared objects (server, config, managers) MUST be created via
@lru_cachefactories in<app>/runtime.py - Tests MUST monkey-patch the factory or alias before importing tool modules
- There MUST be exactly one
ConnectionManagerinstance per server process
All tools MUST return Dict[str, Any]:
{"success": True, "data": <result>} # Success
{"success": False, "error": "<specific, actionable message>"} # Error
{"success": True, "requires_confirmation": True, "preview": <payload>} # Mutation preview- Exceptions MUST NOT escape tool functions. Catch, log with
exc_info=True, return error dict. - Error messages MUST include the operation that failed (e.g.,
"Failed to list devices: ..."not juststr(e)). - Raw tracebacks MUST NOT be exposed to MCP clients.
All state-changing tools MUST implement preview-then-confirm:
confirm=False(default): validate input, return preview payloadconfirm=True: execute the mutation on the controller- Bypass mode injects
confirm=Trueautomatically - Anchor:
packages/unifi-mcp-shared/src/unifi_mcp_shared/confirmation.py
All tools MUST include annotations=ToolAnnotations(...) in @server.tool():
- Read-only:
readOnlyHint=True, openWorldHint=False - Mutating:
readOnlyHint=False, destructiveHint=<bool>, idempotentHint=<bool>, openWorldHint=False destructiveHint=Truefor delete, block, reboot, revoke operationsidempotentHint=Truefor update/rename (same args = same result)- All tools:
openWorldHint=False(closed UniFi controller domain)
- All I/O-bound operations MUST use
async/await - No synchronous blocking calls in tool implementations or managers
asyncio.run()MUST NOT be called from within an async context
- All log output MUST go to stderr (stdout is reserved for JSON-RPC in stdio mode)
- Use
%sformat strings in logger calls, not f-strings, for lazy evaluation - Configuration errors SHOULD fail fast at startup with clear guidance
- Hardcoding host, port, credentials, or feature flags in Python source is banned — use
config.yamlwith${oc.env:VAR,default}orUNIFI_-prefixed env vars - Permission category strings MUST be defined in
<app>/categories.py(NETWORK_CATEGORY_MAP, etc.) - Tool-to-module mappings MUST be in
TOOL_MODULE_MAPin<app>/categories.py - Validation models MUST be pydantic BaseModel classes in
packages/unifi-core/src/unifi_core/<server>/models/<domain>.py— never inline JSON schema dicts in tool functions or app-local schemas modules - No monkey-patches in production code
- Anchor:
apps/network/src/unifi_network_mcp/config/config.yaml
UniFi exposes three API surfaces with disjoint ID/auth models: the V2 controller API (session cookie auth, Mongo ObjectIDs, snake_case fields), the public Integration API (X-API-Key auth, UUIDs, camelCase fields), and the UniFi-OS Alarm Manager v2 service (/api/v2/alarms/, session + SuperAdmin auth, UUIDs, snake_case fields). They are not interchangeable.
- Tool families share an ID space. Tools whose returned IDs and accepted IDs are mutually portable form a family. Cross-family ID use is prohibited.
- Tool descriptions MUST scope IDs when they could be confused with another family's IDs. Standard formula: "These IDs are scoped to the tool family — do not pass them to other tools." This applies to MCP tool descriptions, GraphQL type/query descriptions, and REST route descriptions alike.
- Integration API tools MUST require an API key and fail with a clear remediation message when it is missing. The auth check belongs in the manager so every tool in the family inherits it.
- Alarm Manager v2 (
/api/v2/alarms/) requires a SuperAdmin credential. Reach it viauiprotect'sapi_request(..., api_path="/api/v2/alarms/")(no bespoke client). A Protect-scoped account returns 403 — managers MUST map this to an actionable "requires SuperAdmin" error so callers/agents self-diagnose. Blast radius is topology-dependent: broad on a combined UDM console, contained to Protect on a standalone UNVR. - Silent cross-API ID translation is banned unless the mapping is 1:1, stable, and verified against live data. Zones are grandfathered (1:1 by name, stable, unique). Policies are not (membership diverges, no bridge field, V2 has duplicate names).
- Bridging tools must be explicit. If you need to cross ID namespaces, write a named bridging tool (
unifi_resolve_<resource>_id_for_<family>) with documented failure modes. Never bury the bridge inside another tool.
Enforcement: family scoping is currently guidance — descriptions + this rule. There is no runtime or type-level check. Cross-family ID misuse fails at the controller (e.g., 400 from the integration API) and surfaces as a tool error. Stronger enforcement (typed IDs, runtime regex checks, manifest family field) is on the table if and when the integration-API surface grows beyond the current single family.
Reference family (Integration API): unifi_get_firewall_policy_ordering + unifi_reorder_firewall_policies in apps/network/src/unifi_network_mcp/tools/firewall.py.
Skill: add-integration-api-tool (full procedure for contributors).
Two concepts:
Permission Mode — controls mutation handling:
confirm(default): mutations require preview-then-confirmbypass: mutations execute without confirmation- Read-only tools are always allowed
Env var precedence (most specific wins): UNIFI_<SERVER>_TOOL_PERMISSION_MODE > UNIFI_TOOL_PERMISSION_MODE
Policy Gates — hard boundaries that disable actions:
Three-level hierarchy (most specific wins): UNIFI_POLICY_<SERVER>_<CATEGORY>_<ACTION> > UNIFI_POLICY_<SERVER>_<ACTION> > UNIFI_POLICY_<ACTION>
Actions: CREATE, UPDATE, DELETE. Unset = allowed.
All tools MUST remain visible and discoverable regardless of policy gates. Authorization is checked at call time by the permissioned_tool decorator.
- Anchor:
packages/unifi-mcp-shared/src/unifi_mcp_shared/policy_gate.py
All changes MUST follow a golden path. If no path applies, ask before inventing a new pattern.
- Add manager method in
apps/<server>/src/<pkg>/managers/<domain>_manager.py- Anchor (read-only):
apps/network/src/unifi_network_mcp/managers/client_manager.py - Anchor (mutating):
apps/network/src/unifi_network_mcp/managers/firewall_manager.py
- Anchor (read-only):
- Add tool function in
apps/<server>/src/<pkg>/tools/<category>.py- Anchor (read-only):
apps/network/src/unifi_network_mcp/tools/clients.py:lookup_by_ip - Anchor (mutating):
apps/network/src/unifi_network_mcp/tools/firewall.py:create_firewall_policy
- Anchor (read-only):
- Add tool name to
TOOL_MODULE_MAPin<pkg>/categories.py - Run
make manifest - Add tests in
apps/<server>/tests/unit/test_<category>.py - Add
ToolAnnotationsto the@server.tool()decorator - Commit code + manifest + tests together
- Create manager:
apps/<server>/src/<pkg>/managers/<domain>_manager.py- Anchor:
apps/network/src/unifi_network_mcp/managers/routing_manager.py
- Anchor:
- Add
@lru_cachefactory + alias in<pkg>/runtime.py - Create tool module:
apps/<server>/src/<pkg>/tools/<category>.py- Anchor:
apps/network/src/unifi_network_mcp/tools/clients.py
- Anchor:
- Add tool names to
TOOL_MODULE_MAPin<pkg>/categories.py - Add category to the server's
CATEGORY_MAPin<pkg>/categories.py - Run
make manifest - Add tests, update docs and README as needed
- Commit everything together
- Add default to
apps/<server>/src/<pkg>/config/config.yamlwith${oc.env:VAR,default}syntax- Anchor:
apps/network/src/unifi_network_mcp/config/config.yaml
- Anchor:
- Add env var to
.env.examplewith a comment - Document in README.md configuration section
Update tools MUST use the fetch-merge-put pattern. The manager fetches current state, merges the caller's partial updates, and PUTs the full object. The tool layer accepts a partial dict, validates via the domain's pydantic model (<Model>.to_controller_update), and shows a before/after preview.
- Manager method: fetch existing → copy → merge updates → PUT full object
- Anchor:
apps/network/src/unifi_network_mcp/managers/network_manager.py:update_network
- Anchor:
- Define or extend the pydantic model in
packages/unifi-core/src/unifi_core/<server>/models/<domain>.py. Mark mutable fields as such (default mutability); read-only fields usejson_schema_extra={"mutable": False}. - Tool function: validate the caller's partial dict via
<Model>.to_controller_update(fields), fetch current state for preview, useupdate_preview.- Anchor (model):
packages/unifi-core/src/unifi_core/network/models/wlans.py - Anchor (tool):
apps/network/src/unifi_network_mcp/tools/wlans.py:update_wlan
- Anchor (model):
- Tool description MUST include: "Pass only the fields you want to change — current values are automatically preserved."
- Run
make manifest - Add tests covering: partial merge preserves unmentioned fields, not-found returns False, empty update is a no-op
When a tool domain has list/create/update tools, define a shared pydantic model as the single source of truth for field names, types, and mutability. This ensures list output field names are always accepted by create/update tools — preventing silent data loss when callers round-trip fields from list output into create/update calls.
- Create model in
packages/unifi-core/src/unifi_core/<server>/models/<domain>.py- One
BaseModelclass with all fields (mutable + read-only) - Read-only fields marked with
json_schema_extra={"mutable": False} - Export
MUTABLE_FIELDSandREAD_ONLY_FIELDSfrozensets - Co-locate translation helpers:
from_controller(raw),to_controller_create(model),to_controller_update(fields) - Models live in
unifi-coreso both the MCP tool layer and the API server can import them - Anchor:
packages/unifi-core/src/unifi_core/network/models/acl.py
- One
- Refactor tool functions to derive I/O from the model
- List/get tools:
from_controller(raw).model_dump() - Create tool: build model from params →
to_controller_create()→ manager - Update tool: validate keys against
MUTABLE_FIELDS, translate viato_controller_update()→ manager - Anchor:
apps/network/src/unifi_network_mcp/tools/acl.py
- List/get tools:
- Manager layer is unchanged — continues to speak the controller API dialect
- Add a field symmetry test asserting every mutable field is a create param
- Anchor:
apps/network/tests/unit/test_acl_tools.py:TestListAclRules.test_list_and_create_field_symmetry
- Anchor:
- Register the
(server, domain)pair in the cross-layer symmetry test- The matching Strawberry type at
unifi_api.graphql.types.<server>.<domain>must expose every pydanticMUTABLE_FIELDSname with a compatible type annotation - This catches MCP↔API drift at PR time
- Anchor:
apps/api/tests/unit/test_cross_layer_symmetry.py
- The matching Strawberry type at
- Run
make manifest - Commit model + refactored tools + tests together
- Shared logic:
packages/unifi-mcp-shared/src/unifi_mcp_shared/policy_gate.py - Server-specific categories:
CATEGORY_MAPin the app'scategories.py - Enforcement:
permissioned_tooldecorator in the app'smain.py - Policy gates configured via
UNIFI_POLICY_*env vars (no config.yaml section) - Tests in
packages/unifi-mcp-shared/tests/andapps/<server>/tests/unit/
- Choose package:
unifi-core(controller abstractions, no MCP dependency) orunifi-mcp-shared(MCP utilities) - Add module to
packages/<pkg>/src/<pkg_name>/ - Add tests in
packages/<pkg>/tests/ - Run
make core-testormake shared-test
A change is not done unless ALL pass:
make pre-commit # format + lint + sync-skills + test- Follows anchor pattern (thin wrapper, delegates to manager)
- Returns standardized
{"success": bool, ...}response - Added to
TOOL_MODULE_MAPincategories.py -
make manifestrun and manifest committed - Mutating tools implement preview-then-confirm
- Tools returning raw controller secrets redact at the boundary according to response policy (see Response redaction pattern)
- Permission category and action set via decorator kwargs
-
ToolAnnotationsadded - Tests cover success, error, and permission denial paths
- Works in all three registration modes (lazy, eager, meta_only)
- Default in
config.yamlwith${oc.env:VAR,default} -
.env.exampleupdated - README.md configuration table updated
- Version is derived from git tags via
hatch-vcs. MUST NOT manually edit version inpyproject.toml. - Before release tags, update downstream
pyproject.tomldependency ranges when a downstream package requires newly taggedunifi-coreorunifi-mcp-sharedcode; pip only installs versions allowed by the published wheel metadata. - Each app's
tools_manifest.jsonMUST be regenerated (make manifest) and committed before release. - The Cloudflare worker lives in
apps/worker/as a self-contained Node/TypeScript app. It is intentionally excluded from the uv workspace and released fromworker/v*tags via npm; runmake worker-checkfor focused worker changes and rootmake checkbefore merging worker or relay protocol changes.
All three app servers run on StrictKwargFastMCP (a FastMCP subclass in packages/unifi-mcp-shared/src/unifi_mcp_shared/strict_dispatch.py) that intercepts incoming tools/call requests and rejects unknown top-level kwargs against tools_manifest.json BEFORE pydantic's extra="ignore" can silently drop them. Schema-dict drops (free-form *_data dicts) are independently caught by additionalProperties: false from #206. Do not bypass this wrapper; do not patch FastMCP internals to achieve the same effect. The wrapper retires when upstream lands extra="forbid" on FastMCP's tool arg models — at that point StrictKwargFastMCP becomes a no-op guard and can be removed cleanly.
- Anchor:
packages/unifi-mcp-shared/src/unifi_mcp_shared/strict_dispatch.py
Secret-bearing response fields are redacted at egress boundaries, NOT in domain models — from_controller and friends carry real values. Sensitivity is decided by key name in packages/unifi-core/src/unifi_core/redaction.py (is_sensitive_key / redact_sensitive_fields / redact_value); policy is resolved centrally by packages/unifi-core/src/unifi_core/policy.py (should_redact_sensitive_fields) and exposed to MCP apps through packages/unifi-mcp-shared/src/unifi_mcp_shared/response_policy.py. Redact once per surface — MCP tool returns, the REST serializer, GraphQL types, SSE frames, and diagnostics — using redact_sensitive=True by default. Raw secret values are enabled only by response policy (UNIFI_REDACT_SENSITIVE_FIELDS=false or a server-specific override such as UNIFI_NETWORK_REDACT_SENSITIVE_FIELDS=false, UNIFI_PROTECT_REDACT_SENSITIVE_FIELDS=false, UNIFI_ACCESS_REDACT_SENSITIVE_FIELDS=false, or UNIFI_API_REDACT_SENSITIVE_FIELDS=false); do not add per-tool/request opt-out arguments. Redacted values surface as the marker ***REDACTED***; write-back of the marker is rejected centrally in StrictKwargFastMCP.call_tool (MCP) and dispatch_action (API) — do NOT add per-tool marker checks.
- Anchor:
packages/unifi-core/src/unifi_core/redaction.py
- Prefer adding new tool modules and managers over modifying existing ones
- New tool categories get their own manager + tool module (vertical slice)
- Fix root causes, not symptoms
- Consult the anchor files in Golden Paths when unsure which pattern to follow
- If no anchor applies, ask before inventing a new pattern
- If adopting a genuinely new pattern, update this rules file first
Before non-trivial changes, produce a short plan covering: approach, impacted files, which anchors apply, new tests needed, verification steps.
Skip the plan only when all are true: single-file edit, no new behavior or tools, no config/permission/schema changes, no new tests.
- When
capture.ignore_plan_dirs_in_gitis enabled, custom directories incapture.plan_dirsmay be intentionally gitignored after capture into Myco. - Do not force-add files from intentionally gitignored custom plan directories unless the user explicitly asks.
- When orienting in this codebase — finding a feature, locating files relevant to a change, or understanding an unfamiliar subsystem — use Myco first: call
myco tool call myco_cortex --json --input '{"op":"canopy_map"}'as the CLI path, ormyco_cortex({"op":"canopy_map"})via MCP when the host exposes Myco tools cleanly, before falling back to Glob/Grep.