Agent skill
myco:implement-update-tool-fetch-merge-put
Use this skill whenever you are implementing or fixing an update_* tool in unifi-mcp. It covers the mandatory fetch-merge-put pattern, deep_merge semantics, V2 API response gotchas, the confirm double-fetch design, LLM UX requirements for dict params, and when flat params are appropriate instead. Applies even if the user only says "add an update tool for X" without specifying the implementation approach — the pattern is required for all update tools in this project.
Install this agent skill to your Project
npx add-skill https://github.com/sirkirby/unifi-mcp/tree/main/.agents/skills/implement-update-tool-fetch-merge-put
SKILL.md
Implementing an Update Tool: The Fetch-Merge-Put Pattern
All update tools in unifi-mcp follow the fetch-merge-put pattern. Skipping the fetch step causes silent data loss — the PUT wipes every field not included in the payload. Five tools shipped with this bug (issue #113) before the pattern was standardized. This skill teaches you how to do it right.
Prerequisites
- The resource's manager already has a
get_<resource>_by_idmethod. If not, write it first (check the V2 API gotcha below before you do). deep_mergeis importable fromunifi-core. Tests live inpackages/unifi-core/tests/test_merge.py— run them after any merge logic change.- The create tool for this resource already exists. Update tools assume the object exists; if it doesn't, the caller should use create, not update.
Steps
1. Read the golden path reference first
Before writing any code, read network_manager.py:update_network. It is the canonical implementation of the pattern. Let it orient your mental model before you look at the resource you're adding.
2. Implement the four-step pattern
async def update_<resource>(self, resource_id: str, update_data: dict) -> dict:
# 1. Fetch current state
current = await self.get_<resource>_by_id(resource_id)
if not current:
raise ValueError(f"<Resource> {resource_id} not found")
# 2. Deep-copy before mutating (protects the cached response)
import copy
base = copy.deepcopy(current)
# 3. Merge caller's partial dict over the base
merged = deep_merge(base, update_data)
# 4. PUT the fully-merged object
return await self._connection.put(f"<endpoint>/{resource_id}", merged)
All four steps are required. The deep copy in step 2 is not optional — mutating the cached object in-place corrupts subsequent reads within the same session.
3. Understand deep_merge semantics
deep_merge has deliberate rules about what it recurses into:
| Value type | Behavior |
|---|---|
dict |
Merged recursively — sibling keys are preserved |
scalar |
Replaced — caller's value wins |
list |
Replaced entirely — not element-merged |
None |
Replaced — cannot distinguish "clear this field" from "I didn't specify it" |
The list replacement rule is intentional: merging lists would require knowing whether the caller means "append" or "replace," which is ambiguous at the API boundary. If your resource has list fields that need partial updates, document that the caller must pass the full desired list.
4. Handle the V2 API response envelope
Some UniFi endpoints (especially newer ones) wrap responses in a data list:
{ "data": [ { ...actual object... } ] }
If your get_<resource>_by_id doesn't unwrap this envelope, deep_merge will try to merge against the wrapper dict — the PUT will fail or corrupt data. Check the raw response shape in the controller before assuming the object is at the top level. The fix is to unwrap in the getter:
response = await self._connection.get(f"<endpoint>/{resource_id}")
items = response.get("data", [response]) # normalize both shapes
return items[0] if items else None
5. Implement the MCP tool with dict params
The MCP tool wrapping the manager method must use a dict-typed update_data parameter, not individual keyword args. This is an LLM UX requirement: LLMs handle partial updates far better when they can pass a single dict of only what's changing, rather than being forced to specify every field.
@mcp.tool()
async def update_<resource>(
resource_id: str,
update_data: dict,
confirm: bool = False,
) -> str:
"""
Update a <resource>. Pass only the fields you want to change in update_data.
The tool fetches current state and merges your changes — fields you omit are preserved.
Set confirm=True to apply the change, or omit it to preview what would change.
"""
...
Include the "pass only fields you want to change" language in the docstring. The LLM reads docstrings to understand how to call the tool.
6. Implement the confirm preview using a delta, not the merged result
The confirm=False preview must show what is changing, not the full merged object. The full object is noise — the caller already knows the current state. Show the delta:
if not confirm:
current = await manager.get_<resource>_by_id(resource_id)
# Show only the keys the caller is changing
preview = {k: {"before": current.get(k), "after": v}
for k, v in update_data.items()}
return f"Preview (pass confirm=True to apply):\n{json.dumps(preview, indent=2)}"
This is a deliberate design decision, not an oversight. The confirm flow does a second fetch (double-fetch) even though the apply path also fetches — that's intentional to ensure the preview reflects live controller state, not stale cache.
7. Patch all managers for a new utility
If you're adding or changing merge behavior that affects multiple managers, check AGENTS.md for the list of managers that implement update tools. All 11 were patched as part of issue #113. If you're introducing a new shared utility (like deep_merge was), update all of them in the same PR — partial patches leave the codebase inconsistent.
Create vs. Update Asymmetry
Update tools use dict params. Create tools use flat keyword params. This is intentional:
- Create: The caller is specifying a complete object from scratch. Flat params with defaults make the required fields clear and the optional ones discoverable.
- Update: The caller is expressing a delta. A dict is the natural representation of "change these specific things."
Don't mirror the create tool's signature when building the update tool. They're solving different problems.
Regression Test Standard
Every update tool must have a test that verifies non-passed fields are preserved after the update. The pattern:
def test_update_preserves_unspecified_fields():
original = {"name": "original", "vlan": 10, "notes": "keep me"}
mock_get.return_value = original
await manager.update_<resource>("id-1", {"name": "new-name"})
put_payload = mock_put.call_args[1]["json"]
assert put_payload["vlan"] == 10 # preserved
assert put_payload["notes"] == "keep me" # preserved
assert put_payload["name"] == "new-name" # updated
This test pattern catches the original bug class (PUT without fetch) at the test level.
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
myco:community-pr-review
Use this skill when reviewing or merging any community PR in unifi-mcp — even if the user just says "take a look at this PR" or "can we merge this." Covers the complete quality gate checklist (f-string logger ban, validator registry registration, doc site update ordering), the fork-edit model for trusted contributors, org-fork push limitations, the dual-subagent review pattern, and PR body standards. Apply this skill before approving any externally-authored PR, before running the merge command, and when auditing recently merged PRs for compliance.
unifi-access
How to manage UniFi Access door control — locks, credentials, visitors, access policies, and events. Use this skill when the user mentions UniFi Access, door locks, door access, building access, NFC cards, PIN codes, visitor passes, access policies, access schedules, door readers, or any UniFi Access task.
setup
Configure the UniFi Access MCP server — set controller host, credentials, and permissions
unifi-protect
How to manage UniFi Protect cameras and NVR — view cameras, smart detections, recordings, snapshots, lights, sensors, and the Alarm Manager. Use this skill when the user mentions UniFi cameras, security cameras, NVR, recordings, motion detection, person detection, snapshots, RTSP streams, floodlights, sensors, chimes, arming/disarming the alarm, or any UniFi Protect task.
security-digest
Generate a security digest summarizing events across UniFi Protect cameras, Access door events, and Network firewall activity. Use when asked about what happened overnight, security summary, event digest, recent activity, or reviewing camera and access events.
setup
Configure the UniFi Protect MCP server — set NVR host, credentials, and permissions
Didn't find tool you were looking for?