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
- If the change touches the API surface (a Strawberry type, resolver field, REST route, or serializer), regenerate the API artifacts. These are NOT produced by
make manifest— there is no make target for them, so it is easy to miss and the drift gates then fail at PR time:apps/api/src/unifi_api/graphql/schema.graphql(GraphQL SDL),apps/api/openapi.json, and both docgen reference docsapps/api/docs/graphql-reference.mdANDapps/api/docs/openapi-reference.md(the REST-endpoint reference)- Regen: SDL via
print(str(schema)); openapi viaapp.openapi(); both reference docs at once viapython -m unifi_api.graphql.docgen(it writes graphql-reference.md AND openapi-reference.md — calling onlyrender_reference()misses the REST one). Exact commands are in the drift gates' docstrings (apps/api/tests/graphql/test_sdl_drift.py,test_openapi_drift.py,test_docs_drift.py)
- 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 - If the change touches the API surface (Strawberry type / resolver / route / serializer):
apps/api/src/unifi_api/graphql/schema.graphql,apps/api/openapi.json, and BOTH docgen docsapps/api/docs/graphql-reference.md+apps/api/docs/openapi-reference.md(runpython -m unifi_api.graphql.docgento regenerate both) regenerated and committed — NOT covered bymake manifest; thetest_sdl_drift/test_openapi_drift/test_docs_driftgates enforce them - 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.