hubitat-mcp-server · git:20261102.fcde2e3 · 2026-11-02 · sha256 c2f2fb322c05ba36

hubitat-mcp-server git:20261102.fcde2e3B

Immutable. This exact content never changes and is served at /api/v1/blob/c2f2fb322c05ba36.

---
name: hubitat-mcp-server
description: Guide for developing and maintaining the Hubitat MCP Rule Server — a Groovy-based MCP server running natively on Hubitat Elevation hubs, exposing 115 tools (36 on tools/list via category gateway proxy) for device control, virtual device management, room management, rule automation, hub admin, file management, app/driver/library management, installed-app visibility, Rule Machine interoperability, native rule CRUD, Easy and legacy Hubitat® Dashboard CRUD, HPM package state introspection, and Developer Mode self-administration.
license: MIT
---

# Hubitat MCP Rule Server — Development Skill

## Project Overview

This project is a **Model Context Protocol (MCP) server** implemented as a native **Hubitat Elevation** Groovy app. It consists of two files:

- **`hubitat-mcp-server.groovy`** — Parent app (MCP server, tool definitions, hub admin tools, helpers)
- **`hubitat-mcp-rule.groovy`** — Child app (individual automation rules with triggers, conditions, actions)

The server exposes an OAuth-secured HTTP endpoint that speaks JSON-RPC 2.0 per the MCP protocol specification. AI assistants connect to this endpoint and invoke tools to control devices, manage automation rules, query hub state, and administer the hub.

The Hubitat-runtime code has no external dependencies -- everything runs inside the Hubitat Groovy sandbox. Development tooling (under `tests/`) does include test frameworks: Spock unit tests via Gradle, a sandbox-pattern lint, an end-to-end live-hub test. See README.md "Testing" for details.

**Documentation files:**
- `README.md` — User-facing documentation
- `SKILL.md` — Developer reference (this file)
- `TOOL_GUIDE.md` — Human-readable tool reference, generated from the guide `hub_get_tool_guide` serves (`python tools/build-tool-guide.py`; edit `getToolGuideSections()`, never the file)

## Architecture

```
┌─────────────────────────────────────────────────┐
│  Hubitat Hub (Groovy Sandbox)                   │
│                                                 │
│  ┌───────────────────────────────────────────┐  │
│  │  MCP Rule Server (parent app)             │  │
│  │  - OAuth endpoint: /apps/api/<id>/mcp     │  │
│  │  - JSON-RPC 2.0 handler                   │  │
│  │  - 115 tools (36 on tools/list + gateways)│  │
│  │  - Device access gate (selectedDevices)   │  │
│  │  - Hub Admin tools (internal API calls)   │  │
│  │  - Hub Security cookie auth               │  │
│  │  - Virtual device mgmt (child devices)    │  │
│  │  - Debug logging system                   │  │
│  │  - Version update checker                 │  │
│  ├───────────────────────────────────────────┤  │
│  │  MCP Rule (child app, one per rule)       │  │
│  │  - Trigger subscriptions & evaluation     │  │
│  │  - Condition evaluation                   │  │
│  │  - Action execution                       │  │
│  │  - Execution loop guard (auto-disable)    │  │
│  │  - Isolated settings & state per rule     │  │
│  └───────────────────────────────────────────┘  │
│                                                 │
│  Hub Internal API (http://127.0.0.1:8080)       │
│  - /hub/advanced/* (memory, temp, db size)      │
│  - /hub2/* (apps, drivers, zwave, zigbee)       │
│  - /app/ajax/*, /driver/ajax/* (code mgmt)      │
│  - /hub/backup, /hub/reboot, /hub/shutdown      │
└─────────────────────────────────────────────────┘
```

## Key Conventions

### File Structure (Section Comments)

The server file is organized with prominent section delimiters:
```groovy
// ==================== APP LIFECYCLE ====================
// ==================== MCP REQUEST HANDLERS ====================
// ==================== DEVICE TOOLS ====================
// ==================== RULE TOOLS (Child App Based) ====================
// ==================== RULE EXPORT/IMPORT/CLONE TOOLS ====================
// ==================== EXPORT/IMPORT HELPERS ====================
// ==================== SYSTEM TOOLS ====================
// ==================== CAPTURED STATE TOOLS ====================
// ==================== VALIDATION FUNCTIONS ====================
// ==================== HELPER FUNCTIONS ====================
// ==================== HUB SECURITY & INTERNAL API HELPERS ====================
// ==================== ITEM BACKUP TOOLS ====================
// ==================== FILE MANAGER TOOLS ====================
// ==================== MCP DEBUG LOGGING SYSTEM ====================
// ==================== DEBUG TOOL IMPLEMENTATIONS ====================
// ==================== HUB ADMIN READ TOOL IMPLEMENTATIONS ====================
// ==================== MONITORING TOOL IMPLEMENTATIONS ====================
// ==================== HUB ADMIN WRITE TOOL IMPLEMENTATIONS ====================
// ==================== HUB ADMIN APP/DRIVER MANAGEMENT ====================
// ==================== DEVICE ADMIN TOOL IMPLEMENTATIONS ====================
// ==================== VIRTUAL DEVICE MANAGEMENT TOOL IMPLEMENTATIONS ====================
// ==================== ROOM MANAGEMENT ====================
// ==================== VERSION UPDATE CHECK ====================
// ==================== CATEGORY GATEWAY PROXY ====================
// ==================== TOOL GUIDE ====================
```

New code should be placed in the appropriate section. New sections should follow the same `// ==== NAME ====` delimiter pattern.

### Category Gateway Proxy (v0.8.0+)

The server uses a **category gateway proxy** pattern to reduce the MCP `tools/list` from 115 items to 36. This keeps frequently-used tools immediately accessible while organizing lesser-used tools behind domain-named gateways. Gateways come in two flavors: `hub_read_<noun>` gateways whose every sub-tool is read-only, and `hub_manage_<noun>` gateways that contain at least one write (mixed read+write or write-only). A tool MAY appear in more than one gateway (multi-membership) — reads are listed in BOTH their mixed `manage_` gateway AND a pure-read `read_` gateway.

**Architecture:**
- `getGatewayConfig()` — defines 23 gateways, each with a description, tools list, and summaries map
- `getToolDefinitions()` — returns 13 core tools + 23 gateway tool definitions (client-visible)
- `getAllToolDefinitions()` — returns all 115 tool definitions (used internally by gateway catalog and `executeTool()` dispatch)
- `handleGateway(gatewayName, toolName, toolArgs)` — catalog mode (no args → full schemas) or execute mode (tool + args → dispatch)

**Gateway calling convention:**
1. AI calls `<gateway>()` with no args → gets full tool schemas (catalog mode)
2. AI calls `<gateway>(tool="tool_name", args={...})` → executes the proxied tool

**23 gateways (8 read + 15 manage):**

Read gateways (`hub_read_*`, every sub-tool read-only):
| Gateway | Tools | Domain |
|---------|-------|--------|
| `hub_read_apps_code` | 10 | List apps/drivers/libraries/bundles, get source, backups (list/get), device-in-use-by lookup, app config inspection with linked pages, HPM package state (read-only) |
| `hub_read_devices` | 6 | List/get devices, device attributes, device events, compatible-device catalog search, Hub Mesh read incl. available device/variable links (read-only) |
| `hub_read_diagnostics` | 6 | Logs page tabs (log history, stats, cloud calls, events, jobs, hub actions) and the MCP log, metrics, memory history, device health, radio details (zwave/zigbee), captured states (read-only) |
| `hub_read_files` | 2 | File Manager list + read (read-only) |
| `hub_read_rooms` | 2 | Room list + get (read-only) |
| `hub_read_rules` | 6 | Custom-engine rule get/test, native rule list, rule health, Visual Rules Builder rule list/read (read-only) |
| `hub_read_variables` | 3 | Hub connector and rule engine variable list/get (+ opt-in dependents) + change history (read-only) |
| `hub_read_dashboards` | 2 | Dashboard list + get config by id, covering both Easy and legacy Hubitat® Dashboards (read-only) |

Manage gateways (`hub_manage_*`, contain at least one write):
| Gateway | Tools | Domain |
|---------|-------|--------|
| `hub_manage_custom_rules` | 8 | Custom-engine rule get/create/update/delete/test/export/import/clone |
| `hub_manage_variables` | 8 | Hub connector and rule engine variables (CRUD + connector + history + Hub Mesh variable share/link/unlink) |
| `hub_manage_rooms` | 5 | Room CRUD |
| `hub_manage_destructive_ops` | 4 | Hub reboot, shutdown, device deletion, and destructive ops by target via `hub_call_destructive_ops` (radio reset/wipe + firmware, network disconnect, cloud-controller disable/enable) (write) |
| `hub_manage_backup` | 4 | Backup management — list code + whole-hub DB backups (+ auto-backup schedule), get code-backup source, restore (apps/drivers/rule snapshots), delete whole-hub DB backups (write) |
| `hub_manage_code` | 10 | Install/update apps+drivers+libraries, install/delete/export HPM-style bundles, delete item (app/driver/library) (write) |
| `hub_manage_devices` | 11 | Device command/swap/replace/create/update (writes, incl. Hub Mesh device link/unlink) + list/get devices, attributes, events, Hub Mesh (reads) |
| `hub_manage_logs` | 3 | Logs page and MCP log read, delete (MCP log clear or one scheduled job), MCP log level |
| `hub_manage_diagnostics` | 7 | Diagnostics, state capture/delete, radio details (zwave/zigbee), memory history, metrics, GC |
| `hub_manage_files` | 4 | File Manager CRUD |
| `hub_manage_radio` | 6 | Z-Wave/Zigbee/Matter radio admin — radio details, configure (`hub_set_zwave`/`hub_set_zigbee`), and radio ops incl. Z-Wave network repair (`hub_call_zwave`/`hub_call_zigbee`/`hub_call_matter`); destructive radio ops are in `hub_manage_destructive_ops` |
| `hub_manage_native_rules_and_apps` | 12 | Rule Machine read/create/update/forms through `hub_set_rule`; create, field updates and native forms for every other classic app through `hub_set_native_app`; runtime control, disable/delete/clone/export/import and rule health. |
| `hub_manage_rule_machine` | 11 | Native Rule Machine read/create/update/forms through `hub_set_rule` + RMUtils list/run/set-paused/boolean + rule health + native-app deletion + Visual Rules Builder get/set/delete. |
| `hub_manage_mcp` | 1 | Developer Mode self-administration — update MCP rule app's own settings, including the device-access scope `selectedDevices` (allowlist-gated; requires `enableDeveloperMode`) |
| `hub_manage_dashboards` | 6 | Dashboard CRUD for both Easy and legacy Hubitat® Dashboards — list/get + create/update/delete/clone touch-friendly device dashboards; Easy update replaces the config wholesale, legacy update edits the nested layout wholesale or via granular tile ops (write) |


**Flat (top-level) tools (13):** `hub_manage_virtual_device` (action enum: "create", "delete"), `hub_get_info` (comprehensive: hardware, health — memory, temp, DB size — and MCP stats always available; PII/location data — name, IP, timezone, coordinates, zip — included whenever the Read master is ON, which is the default, and excluded only when Read is explicitly OFF), `hub_list_modes`, `hub_manage_mode`, `hub_set_mode_manager`, `hub_get_hsm_status`, `hub_set_hsm`, `hub_set_system_settings` (hub name, time zone, location, zip, temperature scale, admin-UI dark mode, network config; a time-zone change reboots the hub and any network change can disconnect it, so both need `confirm`), `hub_create_backup`, `hub_update_firmware` (install the hub's pending platform/firmware update; app/firmware version reads fold into `hub_get_info` via `includeAppUpdate`), `hub_report_issue`, `hub_get_tool_guide`, `hub_search_tools` (BM25 natural language search across all tools).

Device tools (`hub_list_devices` with `scope='owned'` to list only MCP-managed virtual devices, `hub_get_device`, `hub_get_device_attribute` — pass `expectedValue`/`expectedValues` to block-poll the attribute until it matches or times out, optionally refined by `comparator` (eq/ne/gt/gte/lt/lte/between) and `stableForMs` (debounce; must be < timeoutMs) -- a numeric comparator on an attribute that reports a non-numeric value the whole window times out with `nonNumericAttribute: true`; for MULTI-DEVICE convergence pass `deviceIds` (a list, mutually exclusive with `deviceId`, max 20) with `mode` (`all` = converge when every device matches, default; `any` = on the first to match), returning a compact per-device array plus `convergedCount`; `timeoutMs` in MILLISECONDS, default 5000ms = 5 seconds, max 60000ms; polling BLOCKS the MCP request, use sparingly, prefer event-driven flows — `hub_call_device_command` (also takes `commands: [{deviceId, command, parameters?}]`, max 20, mutually exclusive with `deviceId`/`command` and no `waitFor`, to reach several devices in one round trip instead of N -- where nearly all the wall-clock time of a multi-device intent goes), `hub_update_device`, and `hub_list_device_events` with `hoursBack` for a relative window up to 7 days (or `since` for an absolute bookmark -- events after an exact timestamp; round-trip a returned `date`) of device or location event history; omit `deviceId` for mode/HSM/hub-variable/sendLocationEvent location events) live in the `hub_read_devices` / `hub_manage_devices` gateways; `hub_call_device_swap` (replace a device across ALL apps/rules that reference it, via the hub's built-in Swap Device tool) is a write and lives only in `hub_manage_devices`. The MCP custom rule engine tools (`hub_get_custom_rule` — omit `ruleId` to list all custom-engine rules, `detailed=true` for comprehensive diagnostics on one rule — `hub_create_custom_rule`, `hub_update_custom_rule`) live in the `hub_read_rules` / `hub_manage_custom_rules` gateways; this engine is distinct from native Rule Machine, whose reading and authoring use `hub_set_rule` in the `hub_manage_rule_machine` and `hub_manage_native_rules_and_apps` gateways. Native rule forms are the same tool's `form` operation. The latter gateway also contains `hub_delete_native_app` / `hub_get_rule_health`; other classic apps retain generic editing through `hub_set_native_app`. Visual Rules Builder rules (Vue-JSON apps; VRB is the primary engine for new automations) are read/written via `hub_get_visual_rule` / `hub_set_visual_rule` / `hub_delete_visual_rule` in the `hub_manage_rule_machine` gateway (the read is also in `hub_read_rules`).

**Safety gates are preserved:** The Read/Write master gate runs centrally at the top of `executeTool()` (the dispatch chokepoint), and the destructive-tier `confirm`+backup check (`requireDestructiveConfirm(args.confirm)`) runs in the handlers of the destructive write tools. A gateway name routes back through `executeTool()`, which re-applies the master gate per sub-tool before the handler runs. No safety check is bypassed.

### Adding a New Tool (Checklist)

Every new tool requires changes in exactly three places:

**Design rules first** (see `AGENTS.md` § Tool design rules for full rationale and citations):

0a. **Tool name follows the design rules** — `hub_` service prefix, verb from the allowed vocabulary, `manage_` only on gateways (or the narrow flat-multi-action exception), verb-noun order.
0b. **All four annotation hints set explicitly** — `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Defaults for unannotated tools are the cautious posture; explicit is safer than implicit.
0c. **Parameter names are unambiguous** — `device_id` not `id`, `user_id` not `user`. Name parameters after what they semantically are.

**Then the three placement changes:**

1. **Tool definition** in `getAllToolDefinitions()` — a map with `name`, `description`, and `inputSchema` (JSON Schema format)
2. **Case statement** in `executeTool()` — dispatches `toolName` to the implementation method
3. **Implementation method** — prefixed with `tool` (e.g., `toolMyNewTool`)

**Gateway consideration (v0.8.0+):** Decide whether the new tool should be:
- **Core** (on `tools/list` directly) — for frequently-used tools that AI needs instant access to
- **Behind a gateway** — for lesser-used or admin tools. Add the tool name to the appropriate gateway's `tools` list and `summaries` map in `getGatewayConfig()`. The tool definition stays in `getAllToolDefinitions()` but gets filtered out of the client-visible `getToolDefinitions()`.

Additionally, update tool count in:
4. **`packageManifest.json`** — version field + releaseNotes with updated tool count
5. **`README.md`** — "MCP Tools (N total)" header + category table + version history
6. **`SKILL.md`** — description frontmatter + architecture diagram tool count
7. **Version strings** — if this is a version bump, update ALL version references (search for the current version string)

### Tool Definition Pattern

```groovy
[
    name: "tool_name",
    description: """Concise first line describing what the tool does.

Additional guidance for the AI on when/how to use this tool.
Include performance notes, pagination guidance, or behavioral expectations.

For write tools, include safety warnings and mandatory pre-flight checklists.""",
    inputSchema: [
        type: "object",
        properties: [
            requiredParam: [type: "string", description: "What this param is"],
            optionalParam: [type: "boolean", description: "What this does", default: false],
            enumParam: [type: "string", enum: ["value1", "value2"], description: "Allowed values"]
        ],
        required: ["requiredParam"]
    ]
]
```

**Canonical example with annotations (new tools follow this shape; no `outputSchema` — publication has been removed, see AGENTS.md § Schema design):**

```groovy
[
    name: "hub_get_room_health",
    description: """Returns a health verdict for one room.

Returns a structured map of room state plus a single `healthy` boolean roll-up so
the AI can decide whether to drill in. Use this before sending commands to devices
in the room — it surfaces stale-state warnings the device-level tools don't.""",
    inputSchema: [
        type: "object",
        properties: [
            room_id: [type: "string", description: "Room ID (string). Use hub_list_rooms to discover IDs."]
        ],
        required: ["room_id"]
    ],
    annotations: [
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false
    ]
]
```

Full rationale and citations: `AGENTS.md` § Tool design rules.

Rules:
- `inputSchema` root is always `type: "object"` with `properties`
- `required` array is only present when there are required params
- No-argument tools use `properties: [:]`
- No `outputSchema` or `structuredContent`: tool results are returned as JSON text in `content` (AGENTS.md § Schema design)
- Descriptions should include usage guidance for the AI (this text is what the LLM sees when deciding which tool to call)
- Write tools must have strong safety warnings in their descriptions with mandatory pre-flight checklists

### executeTool() Dispatch Pattern

```groovy
case "tool_name": return toolMyNewTool(args)
```

- Arguments are destructured from `args` inline: `return toolFoo(args.deviceId, args.offset ?: 0)`
- Some tools pass the entire `args` map: `return toolBar(args)`
- Default values use the Elvis operator `?:` inline
- Tools are grouped by category with comment headers
- `executeTool()` runs every leaf tool inside `_executeToolWithLoadLimiterRetry`: a call the hub's per-app load limiter refused ("generates excessive hub load", thrown or as a `success:false` result) resets the hub's app runtime statistics while the Advanced toggle `autoResetAppRuntimeStats` and the Write master are on and, when that reset answered ok, the tool is in `getIdempotentToolNames()` and less than half the request's time budget is spent, is repeated once; any other write is returned with a note instead of repeated. The limiter compares this app's busy time plus that of the app or device it calls (its own a second time when it calls itself) with uptime × threshold. `handleMcpRequest()` and the request-driven workers wrapped in `_loadGuardTimed` (MRTR slice, auto-continue and cleanup, the Logs-page and native-log fetches, debug-log history, package deploy) add their wall time; other callbacks such as `checkForUpdate` and event handlers are not counted, and the Logs-page check corrects for them. The guard keeps a class-memory estimate of this app's busy time; at 50 % of uptime × threshold `_loadGuardCheckJob` reads the hub's own total for this app from the Logs page, replaces the estimate with it, and resets only when that real total is at 50 %.

### Tool Implementation Pattern

```groovy
def toolMyNewTool(args) {
    // 1. Read/Write masters are enforced centrally in executeTool() by tool
    //    classification (getReadOnlyToolNames) -- no per-handler master gate needed.
    //    Destructive write tools additionally call:
    requireDestructiveConfirm(args.confirm)   // confirm=true + backup <24h (destructive tools only)

    // 2. Input validation
    if (!args.requiredParam) throw new IllegalArgumentException("requiredParam is required")

    // 3. Implementation with error handling
    try {
        // ... do work ...
        mcpLog("info", "component-name", "Description of what happened")
        return [
            success: true,
            // ... result fields ...
        ]
    } catch (Exception e) {
        mcpLog("error", "component-name", "What failed: ${e.message}")
        return [
            success: false,
            error: "Human-readable error: ${e.message}",
            note: "Actionable guidance for the user or AI"
        ]
    }
}
```

Conventions:
- **Validation errors**: Throw `IllegalArgumentException` with descriptive message (caught by `handleToolsCall` and returned as a tool result with `isError: true`, per the 2026-07-28 tools page; only protocol faults stay on JSON-RPC `-32602`)
- **Runtime errors**: Return `[success: false, error: ..., note: ...]` maps (don't throw, so the AI gets useful error info)
- **Device IDs**: Always compare and return as strings: `device.id.toString()`
- **Logging**: Use `mcpLog(level, component, message)` for MCP-accessible logs
- **Hub properties**: Wrap in individual try/catch with `"unavailable"` fallback (hub properties can throw depending on firmware)

### Safety Gate Pattern

Access control has a central master gate plus a destructive confirmation tier, with deny-only advanced overrides on top.

**Two universal masters — Read and Write (both default ON).** A single classification-driven gate at the top of `executeTool()` (the dispatch chokepoint) enforces them: a tool in `getReadOnlyToolNames()` is blocked when `settings.enableRead == false` ("Read tools are disabled…"); every other (write) tool is blocked when `settings.enableWrite == false` ("Write tools are disabled…"). Only an explicit `== false` blocks — null/unset is ON. Gateway *names* are skipped here (they re-enter `executeTool()` per sub-tool, which is gated on re-entry). Reads include device/hub/variable/diagnostics reads, `hub_get_tool_guide`, `hub_search_tools`, etc.; everything else is a write.

**`requireDestructiveConfirm(args.confirm)`** — runs in the handlers of the destructive/sensitive write tools, orthogonal to the masters (the Write master already gated them centrally). Two-layer check:
1. `args.confirm` must be `true` (explicit confirmation parameter)
2. A hub backup within the last 24 hours: `state.lastBackupTimestamp` first; when that stamp is stale/missing, the gate falls back to the hub's own local backup list (`GET /hub2/localBackups` via `_latestLocalHubBackupEpoch()`) — a scheduled/UI backup is a real recovery point — and caches a fresh find back into the stamp (issue #361)

Exception: `toolCreateHubBackup` checks `confirm` directly without requiring a prior backup (it IS the backup operation).

**Advanced per-tool / per-gateway overrides (deny-only).** `settings.disabled_tools` / `settings.disabled_gateways` (set under *Advanced: Per-tool Overrides*) feed `getEffectiveDisabledTools()` → `getHiddenToolNames()`, hiding tools from `tools/list` and `hub_search_tools`, and an `executeTool()` guard rejects a cached call with a distinct error ("…is disabled in Advanced settings (Per-tool Overrides)…"). They apply below the masters (can only turn things OFF) and a disabled tool remains documented in `hub_get_tool_guide`.

**`backupItemSource(type, id)`** — Automatic item-level backup for modify/delete operations:
- Called by `hub_update_app`, `hub_update_driver`, `hub_delete_item` (type=app|driver) before making changes
- Fetches current source code and saves as a `.groovy` file in the hub's local File Manager via `uploadHubFile()`
- Metadata (type, id, version, timestamp, fileName, sourceLength, sizeBytes) is indexed in the File Manager file `mcp-backup-index.json` (kept out of app state, since every `atomicState` access parses the whole state blob), keyed by `"app_<id>"`, `"driver_<id>"`, `"library_<id>"`, `"prerestore_<type>_<id>[_undo]"` or `"rm-rule_<id>_<ts>[-<uuid>]"`. A legacy `atomicState.itemBackupManifest` is merged into the file on first access (file entries win) and removed; a missing index is rebuilt from the File Manager listing, adopting older backup files. A listed index that cannot be read is never rebuilt over: the load throws, nothing is cached, and backup reads and backup-taking writes return `success: false`. An unparseable index is copied to `mcp-backup-index.corrupt-<ts>.json` before the rebuild
- 1-hour window: if a backup of the same item exists within the last hour and is not pending deletion, it is kept (preserves the pre-edit original across a series of edits)
- No count limit: entries are evicted only when indexed backup bytes exceed the `backupBudgetMb` setting (default 10 MB, clamped to 1..1000000, read from settings on each publication). Over the budget, entries go oldest first across every item and kind (a plain FIFO); only the entry being published and an active restore target are never evicted, so an item's only entry goes when it is the oldest; when those protected entries alone exceed the budget nothing is evicted and the index stays over it until a later publication's eviction reaches them, oldest first. The trimmed index is committed first, then files no entry references are deleted via `deleteHubFile()` (a failed delete leaves an orphan file, never a dangling entry)
- Not needed for install tools (nothing to lose when creating new)
- Files persist even if MCP app is uninstalled; accessible at `http://<HUB_IP>/local/<filename>`
- Requires firmware ≥2.3.4.132 for `uploadHubFile()` support

**Item Backup Tools** (3 tools — reads available under the Read master, restore under the Write master):
- `hub_list_backups` — lists all backups with metadata (type, id, version, age, size) and direct download URLs
- `hub_get_backup` — retrieves full source code from a backup via `downloadHubFile()` by key (e.g., `app_123`); returns source inline for files ≤60KB, otherwise provides download URL
- `hub_restore_backup` — reads backup via `downloadHubFile()` and writes the source through the hub's save endpoint directly (requires the Write master); for app/driver restores, first captures the current source under a distinct pre-restore key (returned as `preRestoreBackup` / `undoHint`). A failed required capture aborts before saving. Only an already-matching retry may succeed without verified undo, with `undoAvailable=false` and a warning. Rule snapshots use their own replay path; library restores use `hub_update_library` with the saved source
- Every tool response includes `howToRestore` and `manualRestore` instructions for user recovery without MCP
- All operations are fully local — no cloud involvement

**File Manager Tools** (4 tools):
- `hub_list_files` — lists files via `/hub/fileManager/json` internal API endpoint, optionally narrowed by a `filter` name substring; available under the Read master
- `hub_read_file` — reads file via `downloadHubFile()`; returns content inline for files ≤60KB, otherwise provides download URL; available under the Read master
- `hub_write_file` — writes via `uploadHubFile()`; requires the Write master + confirm + a recent backup; automatically backs up existing file before overwriting (backup named `<original>_backup_<timestamp>.<ext>`; the newest 5 copies per file name are kept)
- `hub_delete_file` — deletes via `deleteHubFile()`; requires the Write master + confirm + a recent backup; automatically backs up file before deletion (same 5-copy cap)
- File name validation: must match `^[A-Za-z0-9][A-Za-z0-9._-]*$` (no spaces, no leading period)

**Device Authorization Safety** (v0.7.2+):
- Device tools require AI to confirm before using non-exact device matches
- If user specifies an exact device name that matches, AI can use it directly
- If no exact match: AI must suggest similar devices and **ask user to confirm** before using any of them
- When a tool fails (e.g., `hub_manage_virtual_device`), AI must report the failure — not silently use existing devices as a workaround
- This prevents accidentally controlling critical systems (HVAC, locks) when user meant a different device
- The `hub_delete_device` tool has its own extensive safety checklist (requires recent backup, explicit confirmation, audit logging)

**Optimized Tool Descriptions** (v0.7.2+):
- Tool descriptions reduced by ~387 lines for better token efficiency
- Based on MCP best practices from Anthropic and modelcontextprotocol.io
- All critical safety rules preserved: pre-flight checklists, confirm requirements, backup requirements
- Descriptions follow "explain like to a new hire" principle — concise but complete
- Reduces context consumption when tools are loaded into AI context
- New `hub_get_tool_guide` tool provides detailed reference on-demand (embedded in server, accessible via MCP)

**Custom-engine Rule Deletion Safety** (hub_delete_custom_rule):
- Automatically backs up rule to File Manager before deletion as `mcp_rule_backup_<name>_<timestamp>.json`
- Backup includes full rule export (triggers, conditions, actions, device manifest)
- Restore via: `hub_read_file(fileName)` → `hub_import_custom_rule(exportData: <json>)`
- **Test rules**: Set `testRule: true` in `hub_create_custom_rule` or `hub_update_custom_rule` to skip backup on deletion
- `skipBackupCheck: true` parameter forces skip regardless of testRule flag (rarely needed)
- Test rule flag visible in `hub_get_custom_rule` (both single-rule and list mode, i.e. with `ruleId` omitted) responses
- Gated by the Write master only — no destructive `confirm`+backup tier (rules are MCP-managed, not hub-level resources)

### Hub Internal API Helpers

Three helpers for calling the hub's internal HTTP API at `http://127.0.0.1:8080`:

| Helper | Use Case | Content Type |
|--------|----------|--------------|
| `hubInternalGet(path, query?, timeout?)` | Read endpoints (default 30s timeout), returns response text | text |
| `hubInternalPost(path, body?)` | Simple POST endpoints (reboot, shutdown, zwave repair) | text |
| `hubInternalPostForm(path, body, timeout?)` | Form-encoded POST (app/driver install/update) | `application/x-www-form-urlencoded` |

All three:
- Call `getHubSecurityCookie()` and attach cookie header if Hub Security is enabled
- Use `shouldRetryWithFreshCookie(e, isRetry)` to detect 401/403 errors and clear the cached cookie for automatic re-auth retry
- Use `ignoreSSLIssues: true`

### Shared Helper Methods (v0.7.6+, updated v0.7.7)

| Helper | Purpose |
|--------|---------|
| `currentVersion()` | Single source of truth for version string |
| `buildRuleExport(ruleData)` | Build portable rule export map (used by export and delete backup) |
| `toolInstallItem(type, args)` | Shared install logic for apps and drivers |
| `toolDeleteItem(type, idParam, deletePath, args)` | Shared delete logic for apps and drivers |
| `hubInternalPostJson(path, jsonBody, isRetry?)` | POST to hub internal API with JSON body (used by library endpoints); returns parsed Map/List or null |
| `updateRuleFromParent(data)` | Child app method — handles all rule updates including enable/disable via `enabled=true/false` |
| `shouldRetryWithFreshCookie(e, isRetry)` | Hub Security auth retry detection |
| `clampPercent(value)` | Clamp integer to 0-100 range (in rule.groovy) |
| `rescheduleSunTrigger(type, handler)` | Shared sunrise/sunset trigger rescheduling (in rule.groovy, v0.7.7+) |

### Hub Security Cookie Pattern

**Retired on firmware >= `hubSecurityRetiredFw()` (2.5.0).** Hubitat exempts an app's own
loopback requests to `127.0.0.1:8080` from the admin-UI login, so these credentials never did
anything there — verified live on 2.5.1.181 with Hub Login Security enforcing: `/hub/advanced/*`,
`/hub2/hubData` and `/hub/details/json` 302 a LAN browser to `/login` while returning real data
through `hubInternal*`. On such a hub `_hubSecurityObsolete()` is true, the settings section is
not rendered, `updated()` AND the top of every MCP request call `_retireHubSecuritySettings()` to
drop the stored credentials, force the toggle off and clear the cached cookie, and
`getHubSecurityCookie()` returns null before reading any setting.

The full path, with the new guard first:

```groovy
def getHubSecurityCookie() {
    // 0. Return null if _hubSecurityObsolete() (firmware >= 2.5.0)
    // 1. Return null if Hub Security not enabled
    // 2. Return cached cookie if not expired (30-minute cache)
    // 3. POST to /login with username/password
    // 4. Extract cookie from Set-Cookie header
    // 5. Cache with 30-minute expiry
}
```

The cookie is cached in `atomicState.hubSecurityCookie` with expiry in `atomicState.hubSecurityCookieExpiry`.

`_hubSecurityObsolete()` reads `location.hub.firmwareVersionString` itself and treats an
unreadable version as NOT obsolete — it deliberately avoids `_firmwareAtLeast`'s assume-modern
default for a blank version, which would wipe a working install's credentials on an
unidentifiable hub.

Native requests use the fixed `http://127.0.0.1:8080` loopback endpoint on the hub. Login credentials and cookies are plaintext on that local connection; they are not sent to the hub's LAN address or cloud relay by this helper. This trusts the hub's operating system and locally installed code: a process able to inspect loopback traffic can read them. Install only trusted apps/drivers and restrict hub administration. Do not replace this origin with a remote HTTP address; remote credential transport requires authenticated TLS. The local HTTP connection does not provide confidentiality against a compromised hub.

### State Management

**Parent app `state.*`:**

| Key | Type | Purpose |
|-----|------|---------|
| `accessToken` | String | OAuth token for MCP endpoint |
| `ruleVariables` | Map | Global variables shared across rules |
| `debugLogs` | Map | Small `{config: {logLevel, maxEntries}}` only; entries use a bounded class cache backed by native Past Logs |
| `lastBackupTimestamp` | Long | Newest known hub backup epoch ms (24-hour write safety gate; stamped by hub_create_backup or refreshed from the hub's local backup list on a gate fallback) |
| `updateCheck` | Map | `{latestVersion, checkedAt, lastError}` |

The backup index for app/driver/library source, rule snapshots, and pre-restore undo is the File Manager file `mcp-backup-index.json`, not app state.

Legacy device captures live in per-app class-static memory, not `state` or `atomicState`. Existing `capturedDeviceStates` keys are imported and removed on capture-store access; captures are intentionally lost on app code reload or hub restart. See `docs/capture-storage.md`.

MCP log entries are emitted through Hubitat's native logger with structured metadata for recovery after reload. `atomicState.debugLogGeneration` is a small clear marker; do not put log entries or history arrays back into `state` or `atomicState`. The MCP memory view is bounded to 100 entries, and native recovery follows Hubitat's shared Past Logs retention. See `TOOL_GUIDE.md` for logging behavior.

**Child app uses `atomicState`** for `triggers`, `conditions`, `actions`, `localVariables`, `durationTimers`, `durationFired`, and `cancelledDelayIds`. This is critical — `atomicState` provides immediate persistence and prevents race conditions when scheduled callbacks (`runIn`) fire in separate execution contexts. Always use read-modify-write pattern with atomicState maps:
```groovy
def timers = atomicState.durationTimers ?: [:]
timers[key] = value
atomicState.durationTimers = timers  // Write back entire map
```
Direct nested mutation (`atomicState.map[key] = value`) silently fails to persist. Regular `state` is used for UI editor state, counters, and timestamps. `cancelledDelayIds` is cleared on `initialize()` since `unschedule()` in `updated()` cancels all pending callbacks.

**Execution Loop Guard** — `executeRule()` tracks recent execution timestamps in `atomicState.recentExecutions`. If a rule fires too many times within a sliding window, it auto-disables (`ruleEnabled = false`), unsubscribes from events, and unschedules all timers. Thresholds are configurable via parent app settings: `loopGuardMax` (default: 30, range: 5–200) and `loopGuardWindowSec` (default: 60s, range: 10–300s). This prevents infinite event loops (e.g., "trigger on Switch A on → action: turn on Switch A") from crashing the hub. When triggered, `notifyLoopGuard()` sends push notifications to any `capability.notification` devices in the parent's selected devices and fires a `mcpLoopGuard` location event (name=`mcpLoopGuard`, value=rule name) that other automations (Rule Machine, etc.) can subscribe to. The rule must be manually re-enabled after fixing the loop. The guard uses `atomicState` for immediate persistence across rapid-fire event handlers.

### Parent-Child Communication

```groovy
// Parent -> Child
childApp.getRuleData()              // Get full rule structure
childApp.testRuleFromParent()       // Dry-run test
childApp.updateRuleFromParent(args) // Update rule data
childApp.getSetting("ruleName")     // Access child settings

// Child -> Parent
parent.getSelectedDevices()         // Access device list
parent.findDevice(deviceId)         // Device lookup
```

### Device Access

Legacy SDK device lookup uses `findDevice(deviceId)`. Native device tools enforce selection through `_requireDeviceToolAccess` before their native requests, as described below:
```groovy
def findDevice(deviceId) {
    if (!deviceId) return null
    // Search selected devices first, then MCP-managed child devices (virtual devices)
    def device = settings.selectedDevices?.find { it.id.toString() == deviceId.toString() }
    if (!device) {
        device = getChildDevices()?.find { it.id.toString() == deviceId.toString() }
    }
    return device
}
```

Devices are accessible from two sources:
1. **`settings.selectedDevices`** — the user explicitly selects which physical/existing devices to expose to MCP (security boundary)
2. **`getChildDevices()`** — MCP-managed virtual devices created via `hub_manage_virtual_device` are automatically accessible without manual selection

`hub_list_devices` lists the union of both (deduplicated by id) from the Devices page list and marks child devices with `mcpManaged: true`.

**Device allowlist bypass (`settings.bypassDeviceAllowlist`, default OFF).** Device reads, commands, configuration writes, inventory, health checks, dependent lookups, swaps and replacements use native hub endpoints. With bypass OFF, access is limited to selected devices plus MCP-owned children. With bypass ON, these operations can reach any existing device; the Read/Write masters, confirmations and operation-specific eligibility checks still apply. MCP-owned virtual inventory remains ownership-scoped. Hub logs, including device-filtered logs, remain readable regardless of device selection or bypass; the Read master still applies. Explicit scope=all inventory and existing administrative force-delete operations retain their documented broader scope. The toggle changes authorization, not transport. On firmware older than 2.5.0, native device operations use the MCP app's Hub Security credentials when Hub Security is enabled. On 2.5.0 and later no credentials are involved -- an app's loopback requests are exempt from the hub login. Its effect is independent of Developer Mode. Native attribute discovery contains reported current states, including their available types and values; unset or cleared attributes can be absent. An explicit missing-attribute read returns null with neverReported, and polling may time out instead of rejecting an unknown name. A command with waitFor can therefore execute before a mistyped attribute times out. Supported-command and argument validation still run before command execution. Attribute discovery is reported-state only on EVERY path (selected devices included): `attributes` in list/detail reads and `declaredAttributes` in details mode carry current states, and an attribute the driver declares but has never set is absent, not unsupported (`attributeCoverage.declarationsComplete: false` says so in-band). Attribute values keep the driver-declared type: an attribute whose native record carries `dataType: NUMBER` is a JSON number, every other value is a string, and the type is stable per attribute; the `hubitat://context` and `hubitat://context-summary` resources are served from one bulk hub read and carry string values without unit suffixes. Whole-population reads -- the two context resources and every `hub_list_devices` filter -- come from that single bulk read, never one native read per device; only a device the bulk read does not cover costs a per-device read (capped at 20 for the resources, with the rest reported as state unavailable). One unreadable device no longer fails `hub_list_devices`: it is listed with `metadataUnavailable: true`, excluded from any active filter, named in `metadataUnavailableIds`, and the response carries `partial: true`; a bypass inventory whose id set could not be vouched for is returned with `idsComplete: false` instead of an error. Swap and replace both verify a bypass-only id against native metadata before any native request. The legacy custom-rule engine keeps its own selection-only device references; bypass does not extend to it. Preferences and dataValues use native writes with independent readback; complete-form saves omit unchanged null groupId/controllerType/roomId/notes/tags/zigbeeId/defaultIcon to preserve null, retain explicit blank and roomId=0 clears, and recover accidentally blanked identity natively. App lifecycle APIs still establish and remove MCP-owned children; device reads and data writes after creation are native.

### Virtual Device Management

Virtual devices are managed via the unified `hub_manage_virtual_device` tool (action enum: "create", "delete") as **child devices** of the MCP Rule Server app using `addChildDevice()` — the officially supported Hubitat API. Key design:

- **`addChildDevice(namespace, driverName, dni, null, [name: ..., label: ..., isComponent: false])`** — 5-argument form with `null` hub ID for cross-firmware compatibility. Namespace is `"hubitat"` for built-in virtual drivers, or the user-supplied namespace for custom drivers.
- **Two mutually exclusive create modes**: `deviceType` (one of 15 built-in virtual driver names, namespace hardcoded to `"hubitat"`) **because** built-in types are a finite validated set requiring only a type name; OR `customDriver={namespace, name}` (user-installed driver with any namespace) **because** custom drivers require both a namespace discriminator and a type name to be uniquely identified on the hub. Exactly one must be provided -- supplying both is an error. Blank-after-trim `deviceType` with `customDriver` also raises the mutex error.
- **`isComponent: false`** — device appears independently in the Hubitat UI, can be edited/deleted, and can be shared with other apps (Maker API, Dashboard, Rule Machine, HA, etc.)
- **`getChildDevices()`** returns only child *devices* (not child apps/rules — those use `getChildApps()`)
- **`deleteChildDevice(dni)`** removes by device network ID
- Auto-generated DNIs use format `mcp-virtual-<hex-timestamp>-<hex-random>` with retry logic to avoid collisions
- Supports 15 built-in virtual device types: Virtual Switch, Virtual Button, Virtual Contact Sensor, Virtual Motion Sensor, Virtual Presence, Virtual Lock, Virtual Temperature Sensor, Virtual Humidity Sensor, Virtual Dimmer, Virtual RGBW Light, Virtual Shade, Virtual Garage Door Controller, Virtual Leak Sensor, Virtual Omni Sensor, Virtual Fan Controller
- For custom drivers: `customDriver={namespace, name}` -- namespace + name are coerced to String then trimmed before use; whitespace-only or numeric values that trim to empty are rejected with a descriptive error before reaching the hub. Any exception from `addChildDevice` on the custom-driver path is translated to an `IllegalArgumentException` pointing to `hub_list_drivers` (fail-closed regardless of hub error text).
- **Response shape** (`hub_manage_virtual_device create`): `{success, message, tips, device: {id, name, label, deviceNetworkId, driverNamespace, driverType, typeName, capabilities, commands, attributes}}`. `typeName` is a deprecated alias for `driverType` **because** existing callers reading `result.device.typeName` after create must not silently break; prefer `driverType` in new code.
- **Response shape** (`hub_manage_virtual_device delete`): `{success, deviceId, deviceNetworkId, deviceLabel, message}`.
- **Response shape** (`hub_list_devices` with `scope='owned'`): the ordinary device record (`{id, label, name, driver, room, disabled, deviceNetworkId, lastActivity, protocol, mcpManaged, currentStates}`), plus `capabilities`, `attributes`, `commands` and `driverNamespace` with `format='detailed'`. `driverNamespace` is the namespace persisted as a device data value at create time, else the native device type namespace.
- **Error contract (N.36)**: `customDriver` not-found throws `IllegalArgumentException` (an `isError: true` tool result) because the bad driver spec is caller-supplied and recoverable by fixing args. Built-in not-found throws `RuntimeException` (isError:true) because hub firmware not including a built-in driver is a platform condition, not a caller error. This is a deliberate exception to the general `return [success:false]` convention -- the two-class split reflects the distinction between caller-fixable vs platform-gap failures.
- Requires the Write master (with confirm + backup verification) for create/delete operations

#### hub_update_device Tool

The `hub_update_device` tool changes settings on any accessible device: selected devices and MCP-owned children with bypass OFF, or any native device ID with bypass ON. It takes `fields` (the editable fields `hub_get_device(mode='configuration')` lists) and `preferences` (the driver's declared preferences), and saves them the way the device page does: one request per page section, then one native read-back that confirms every requested field.

- **Device Info** fields (`label`, `name`, `deviceNetworkId`, `room`, `tags`, `notes`, `defaultIcon`, history limits, `deviceTypeId`, `zigbeeId`, `dashboardIds`, Hub Mesh flags) go in one `/device/update` form carrying the complete current model, as the page's Save does. `room` takes a name (case-insensitive) or id and is sent as `roomId` (`0` clears).
- **preferences** with `showOnHome`, `defaultCurrentState` and `retryEnabled` go in one `/device/preference/save`, which also runs the driver's `updated()`. Multi-select enum values are sent as JSON text so the driver receives a List.
- **assistants** use `/device/updateAssistants` with all three current assignments; **enabled** uses `/device/disable`; **dataValues** use the driver's `updateDataValue` through `/device/runmethod`.

All routes require the Write master. Validation runs before mutation; runtime failures and unconfirmed readbacks are reported without claiming a successful field change. Complete forms preserve unrelated native fields and refuse an incomplete preservation source.

### Room Management

5 tools for full room CRUD, using `POST /room/save` (JSON) and `getRooms()` for verification:

| Tool | Access Gate | Description |
|------|------------|-------------|
| `hub_list_rooms` | None | Lists all rooms with IDs, names, device counts via `getRooms()` |
| `hub_get_room` | None | Room details with full device info/states. Accepts name (case-insensitive) or ID |
| `hub_create_room` | Write master + confirm | Creates room via `POST /room/save` with `roomId: 0` (Grails create convention) |
| `hub_delete_room` | Write master + confirm | Deletes room via `POST /room/delete/<id>` or `GET /room/delete/<id>`. Devices become unassigned |
| `hub_update_room` | Write master + confirm | Renames room via `POST /room/save` with existing `roomId` and new `name`. Preserves device assignments |

**Key API details:**
- All room mutations use `POST /room/save` at `http://127.0.0.1:8080` with `Content-Type: application/json`
- The JSON body uses `roomId` (not `id`) — the API returns `{"roomId":null,"error":"Invalid room id"}` if `id` is used
- Body format: `{"roomId": <int>, "name": "<string>", "deviceIds": [<int>, ...]}`
- For room creation, `roomId: 0` triggers create behavior (Grails convention)
- `getRooms()` is a built-in Hubitat SDK method returning `[[id:1, name:"Bedroom", deviceIds:[5, 6]], ...]`
- All write tools verify the operation via `getRooms()` after the API call
- Form-encoded bodies return 500 — the endpoint strictly requires JSON

### Version Management

As of v0.7.6, most version references in `hubitat-mcp-server.groovy` are **centralized** via the `currentVersion()` function. Runtime code (handleInitialize, toolExportRule, toolGetLoggingStatus, toolGenerateBugReport, toolGetHubInfo, mainPage display) all call `currentVersion()` instead of hardcoding the version string. As of v0.7.7, tool execution errors use `isError: true` in the MCP result per protocol spec instead of JSON-RPC errors.

When bumping the version, update these locations:
- `currentVersion()` return value — **single source of truth** for runtime version
- File header comment (`hubitat-mcp-server.groovy` line 7) — for source code readers
- `hubitat-mcp-rule.groovy` — file header comment version
- `packageManifest.json` — version field, dateReleased, and releaseNotes
- `README.md` — "New in vX.Y.Z" section + Version History table

Search for the current version string across all files to verify no hardcoded references remain.

### Groovy/Hubitat Idioms

- **Elvis operator** `?:` for defaults: `args.offset ?: 0`
- **Safe navigation** `?.` everywhere: `device.capabilities?.collect { it.name }`
- **Dynamic method invocation**: `device."${command}"(*convertedParams)` with spread operator
- **`settings.*`** for user-configured preferences (persisted by Hubitat framework)
- **`state.*`** for persistent app-level storage
- **`atomicState.*`** for thread-safe persistent storage
- **`location.*`** for hub-level info (modes, timezone, HSM, etc.)
- **`httpGet`/`httpPost`** for synchronous HTTP; `asynchttpGet` for async
- **`subscribe(device, attribute, handler)`** for device event subscriptions
- **`schedule(cron, method)`** / `runIn(seconds, method)` for scheduling
- **`render(contentType: "application/json", data: jsonString)`** for HTTP responses
- **`groovy.json.JsonOutput.toJson()`** and **`new groovy.json.JsonSlurper().parseText()`** for JSON

### Hubitat Sandbox Limitations

The Hubitat Groovy sandbox restricts:
- No `Eval.me()` or dynamic code evaluation
- No file system access
- No arbitrary network access (only `httpGet`/`httpPost` to allowed destinations)
- No thread creation
- Limited class imports (no `java.io`, limited `java.net`)
- `state` writes are eventually consistent; `atomicState` is immediately consistent
- Hub properties (e.g., `hub.uptime`, `hub.zigbeeChannel`) can throw exceptions on some firmware versions — always wrap in try/catch

### Hub Internal API Endpoints Reference

These are undocumented endpoints on the Hubitat hub at `http://127.0.0.1:8080`:

**Read endpoints (GET):**
| Path | Returns |
|------|---------|
| `/hub/advanced/freeOSMemory` | Free memory in KB (text) |
| `/hub/advanced/internalTempCelsius` | CPU temperature (text) |
| `/hub/advanced/databaseSize` | Database size in KB (text) |
| `/hub/advanced/getExcessiveLoadThreshold` | The per-app load limiter's threshold, % of uptime (stock 33). Read by the load-limiter guard once per code load and again by each check job. |
| `/hub/advanced/resetAppRuntimeStats` | Zeroes every app's App Stats busy counter (uptime is untouched); answers `ok`. Used by the load-limiter guard, the refused-call retry in `executeTool()`, and `hub_delete_logs(mode='appRuntimeStats')`. |
| `/hub/advanced/blinkLED` | Fires the hub's identify-LED sequence (blue → red → green) once. Returns the literal text `true`. Single GET, no body, self-resetting. Surfaced via the opt-in `identifyHub` flag on `hub_get_info` and `hub_get_device_health`. |
| `/hub2/userAppTypes` | Apps Code definitions (JSON array: id, name, namespace). Each entry is a code definition, NOT a running instance -- child-app templates appear here even with zero active instances. Distinct from the `userAppTypes[]` key embedded in `/hub2/appsList` (which is the instance tree). Used by `hub_list_apps` and `hub_list_hpm_packages` (includeDrift=true; orphan-app detection). |
| `/hub2/userDeviceTypes` | Drivers Code definitions (JSON array: id, name, namespace, capabilities, lastModified, usedBy[]). Despite the name, this is the Drivers Code registry (code definitions, not device instances). Used by `hub_list_drivers` and `hub_list_hpm_packages` (includeDrift=true; orphan-driver detection). Note: hub uses `userDeviceTypes` for the driver registry while apps use `userAppTypes` -- the naming asymmetry is a hub convention, not an error. |
| `/hub2/zwaveInfo` | Z-Wave radio details (JSON) |
| `/hub2/zigbeeInfo` | Zigbee radio details (JSON) |
| `/app/ajax/code` with query `id=<id>` | App source code (JSON: source, version, status) |
| `/driver/ajax/code` with query `id=<id>` | Driver source code (JSON: source, version, status) |
| `/hub2/userLibraries` | Installed user libraries (JSON array: id, version, name, namespace, author, description, usedByAppTypes, usedByDeviceTypes -- no source field) |
| `/library/list/single/data/<id>` | Single library with full source (JSON array of one: id, version, name, namespace, source, lastModified, usedByDeviceTypes, etc.) |
| `/hub/backupDB` with query `fileName=latest` | Creates fresh backup and returns .lzf binary |
| `/hub/fileManager/json` | Lists all files in File Manager (JSON array: name, size, date) |
| `/hub2/roomsList` | List of rooms as JSON (alternative to `getRooms()` SDK method) |
| `/logs/past/json` | Hub log buffer as JSON array of tab-delimited strings (chronological order, oldest first — reverse client-side for newest-first). Accepts optional `?type=dev&id=<deviceId>` or `?type=app&id=<appId>` to scope server-side to a single source. |
| `/hub2/appsList` | All installed apps (built-in + user) as JSON. Keys: `systemAppTypes[]`, `userAppTypes[]`, `apps[]` (instance tree). Each `apps[]` entry has `{key, id, data: {id, name, type, disabled, user, hidden, appTypeId}, parent: bool, child: bool, children: [...]}`. Used by `hub_list_apps` (`scope=instances`). |
| `/device/fullJson/<id>` | Comprehensive device JSON — includes `appsUsing` array (apps referencing this device: `{id, name, label, trueLabel, disabled}`), `appsUsingCount`, `parentApp`, plus device commands/attributes/settings/dashboards. `device.currentStates` is an OBJECT keyed by attribute name (`{value, dataType, unit, numberValue, date}`); top-level `commands` is an array (`{capability, name, arguments, parameters, relatedAttribute}`). Used by device reads, commands, configuration writes and dependent lookups after the MCP device-access check, regardless of bypass mode. |
| `/device/eventsJson/<id>` | A device's event history as a JSON array (newest-first, no query params): `{id, name, value, unit, descriptionText, source, type, date, isStateChange, deviceId, producedBy, triggered, ...}` -- `producedBy` (one `<a>` to `/installedapp/configure/<id>` or `/device/edit/<id>`, or bare text) and `triggered` (a `<ul>` of `<a>` app links each followed by ` (handlerMethod)`) are hub-rendered HTML, parsed into `{name, appId}` or `{name, deviceId}` / `[{name, appId, handler}]` rows. Used by `hub_list_device_events` for every authorized device (both the recent-N mode and the windowed mode -- limit + attribute + strictly-after window applied client-side). |
| `/device/updateLabel?deviceId=<id>&label=<label>` | Sets a device's label; returns the literal text `true`. Not used by the tools (labels go through `/device/update` or the create). |
| `/device/updateRoom?deviceId=<id>&room=<roomName>` | Assigns a device to a room by NAME and silently CREATES a room for an unknown name. Not used by the tools; `/device/setRoom?deviceId=&roomId=` (what the device list uses) takes the id, and `hub_update_device` sends `roomId` in the `/device/update` form. |
| `/installedapp/configure/json/<id>[/<pageName>]` | SDK-level config-page serialization for any installed app using `dynamicPage()`. Returns `{app, configPage: {name, title, sections: [{title, input: [...], body: [...]}]}, settings, childApps}`. `app` carries identity (label, name, appType, disabled, parentAppId). Sections hold typed inputs with current values. The Web UI itself consumes this endpoint. Used by `hub_get_app_config`. |
| `/installedapp/statusJson/<id>` | Raw Groovy `state` map for any installed app. Returns `{id, appState: [{name, value}, ...], appSettings: [...]}`. `appState[].value` shape varies: live hubs typically return the value already parsed as a Map (JsonSlurper recursively decoded the inner JSON); older firmwares or large payloads may leave it as a JSON-encoded String requiring a second parse. The implementation handles both: if value is already a Map, use it directly; if String, parse again. Used by `hub_list_hpm_packages` (including its `includeDrift=true` mode) to read HPM's `state.manifests` package registry. Gated by the Read master. |
| `/hub2/vrb/devices` | The Visual Rule Builder device picker feed: a flat JSON array of `{id, label, capabilities, temperature, lightEffects, supportedFanSpeeds, buttonCount}` for every hub device regardless of MCP authorization. `hub_list_devices` reads it once per call whenever capabilities are needed (`capabilityFilter`, `fields` naming `capabilities`, `format='context'`). |
| `/hub2/devicesList` | The hub's Devices page list: `{devices: [{key, data: {...}, children: [...], parent, child}]}`, top-level devices sorted by display name with child devices nested under their parent. Each `data` node carries id, name (the display name), secondaryName (the device name when a label is set), type (driver), source (System/User), dni, zigbeeId, protocol flags (isZigbee/isZwave/isMatter/isNetwork/isBluetooth/isVirtual/isLinked), lastActivity, disabled, roomId/roomName, tags, notes, defaultCurrentState, showOnHome, hubMesh and `currentStates` as `[{key, value}]` string display values (no units or types). `hub_list_devices` (every scope), the context resources and the `selectedDevices` validation of `hub_update_mcp_settings` read this one list; `format='detailed'` adds `/device/fullJson/<id>` per listed device for commands and typed attribute values. |

**Write endpoints (POST):**
| Path | Body | Purpose |
|------|------|---------|
| `/hub/reboot` | POST JSON `{rebuildDatabase, purgeLogs}` (both optional booleans, the Reboot dialog's advanced options) | Reboot hub |
| `/hub/shutdown` | none | Shutdown hub |
| `/hub/zwaveRepair` | none | Start Z-Wave network repair |
| `/app/saveOrUpdateJson` | JSON: `{"id": <id\|null>, "source": "<code>", "version": <ver\|1>}` | Install (id=null) or update (id=N) app code. Returns `{success, id, message}`; compile errors ride verbatim in `message`. MUST use `Content-Type: application/json`. `version` is enforced hub-side: a save carrying a stale version is rejected with a version-mismatch error (observed live — a web-UI save after an MCP-driven update errors out). |
| `/driver/saveOrUpdateJson` | JSON: `{"id": <id\|null>, "source": "<code>", "version": <ver\|1>}` | Install (id=null) or update (id=N) driver code — same contract as the app endpoint. |
| `/app/ajax/update` | `id=<id>, version=<ver>, source=<code>` | Legacy form-encoded app-code update; still works on fw 2.5.x (the e2e watchdog still uses it). Response: `{status, errorMessage}`. Same shape at `/driver/ajax/update`. |
| `/library/saveOrUpdateJson` | JSON: `{"id": null, "source": "<code>", "version": null}` | Install new library (id=null creates; id=N updates with version=N for optimistic lock). Returns `{success, message, id, version}`. MUST use `Content-Type: application/json`. |
| `/login` | `username=<u>, password=<p>, submit=Login` | Hub Security login |
| `/device/save` | form-urlencoded device model with `deviceTypeId` | Creates a NEW device from the posted model and redirects to its edit page (not an update; use `/device/update`). |
| `/device/disable` | JSON `{id: <numeric deviceId>, disable: <boolean>}` | Enable or disable a device (`Content-Type: application/json`; MUST be POST, not GET) |
| `/device/runmethod` | JSON: `{"id":<int>, "method":"<command>", "args":[{"type":"<argType>","value":<v>}, ...]}` | Invoke a command on ANY device by id (allowlist-blind). Returns `{"success":true, "message":null}`. Empty `args` for a no-parameter command. Used by `hub_call_device_command` for every authorized device. Also invokes the fixed `updateDataValue` writer with two STRING arguments; data writes require fresh readback. |
| `/device/preference/save` | JSON: `{deviceId, showOnHome, defaultCurrentState, commandRetry, preferences:[{name,type,value}]}` with numeric deviceId | The page's Save Preferences. Rows are a delta (an empty value deletes that setting); the three pane controls are always written (absent = false/empty), so send their current values. A List value is stored comma-joined, which a driver reads as one String; send a multi-select as JSON text. Runs the driver's `updated()`. |
| `/device/update` | form-urlencoded: the FULL device-edit model (name, label, deviceNetworkId, roomId, tags, dashboardIds, ...; boolean true→`on`) | The page's Device Info Save. The hub rebuilds the device from the posted fields, so send every current value; an omitted field stays null. Answers `{"status":"success"}` or `{"status":"failure"}`. Used by `hub_update_device` for every Device Info field. |
| `/room/save` | JSON: `{"roomId": <int>, "name": "<str>", "deviceIds": [<int>,...]}` | Create (roomId=0) or update room. MUST use `Content-Type: application/json` — form-encoded returns 500. Field is `roomId` not `id` |

**Delete endpoints (GET or POST):**
| Path | Method | Purpose |
|------|--------|---------|
| `/app/edit/deleteJsonSafe/<id>` | GET | Delete app (returns JSON with `status: true`) |
| `/driver/editor/deleteJson/<id>` | GET | Delete driver (returns JSON with `status: true`) |
| `/library/edit/deleteJson/<id>` | GET | Delete library (returns JSON with `success: true, message: null`) |
| `/room/delete/<roomId>` | POST or GET | Delete room (try POST first, fall back to GET) |

**Uncertain command outcome:** `outcomeUnknown: true` means the native command request failed without proving whether the device acted. Read the device state or event history before deciding whether to retry; blindly repeating a non-idempotent command can apply it twice.

**Health probe continuation:** Modern, transport-budgeted `hub_get_device_health` calls execute through the existing read-snapshot worker. Traceroute, speedtest, health inventory and an optional identify blink run once per logical call; replay reads the same snapshot. Native endpoint timeouts are unchanged. The existing bounded continuation window can expire first on long speedtests or combined probes, returning `slow_read_timeout` while the worker may still be running; callers must not automatically launch another probe.

**Partial virtual results:** `success: true, partialSuccess: true` means a created device or some inventory entries are usable, but native metadata or namespace verification is incomplete. Inspect the warnings and unreadable device IDs. A created ID/DNI already exists: repair or re-read it rather than creating another device. An entirely unreadable inventory returns `success: false, isError: true`; a partial inventory must not be treated as complete.

### MCP Protocol Implementation

The server speaks the protocol versions in `supportedProtocolVersions()` — `2026-07-28`, `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`, newest first — and serves **two eras off one endpoint**, split by the *value* of the `MCP-Protocol-Version` header:
- **Transport**: Streamable HTTP (not SSE/stdio) with OAuth access token — `?access_token=<token>` on the query string, or equivalently an `Authorization: Bearer <token>` header (PLATFORM behaviour, not this app's code: the Hubitat OAuth layer accepts either — verified manually on firmware 2.5.1.135 against both the local endpoint and the cloud relay; the e2e bearer scenario pins it on whichever single endpoint the suite targets, the cloud relay in CI, including a negative probe that a wrong bearer is refused)
- **Format**: JSON-RPC 2.0 (batch requests supported on the legacy path only)
- **Methods**: `initialize`, `server/discover`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `ping`
- **Resources (issue #366)**: a stateless read-only surface — capabilities advertise `resources: {subscribe: false, listChanged: false}` (no SSE, so subscriptions are impossible by design). The catalog is the tool-guide sections (`hubitat://guide/<section>`, text/markdown, same content as `hub_get_tool_guide`) plus the live context snapshot (`hubitat://context-summary` text/plain and `hubitat://context` application/json). Each resource group mirrors the VISIBILITY of the tool whose content it serves, via `getHiddenToolNames()` — the context pair follows `hub_list_devices`, the guide resources follow `hub_get_tool_guide` — so the Read master AND the #114 Advanced per-tool overrides both apply (hidden from `resources/list`, `-32002` naming the applicable gate on read), and the resources surface can never serve content its tool counterpart is gated from serving. (The best-practice acknowledgment gate is a different, write-only gate that exempts `hub_get_tool_guide`; it does not apply to resources.) Because `resources/read` takes only a `uri` (no pagination to retry with), both context bodies truncate at `_contextResourceByteBudget()` — measured in ESCAPED-envelope characters via `_escapedLen`, so quote escaping and `\uXXXX` expansion of multibyte labels are already priced in, and the rooms index gets its own half-budget cap — with explicit truncation markers pointing at the paginated tool form instead of dying behind the outer `-32603` size guard. Unknown URI on `resources/read` → `-32002` with `data.uri`; `resources/templates/list` returns an empty list. All three results carry `ttlMs`/`cacheScope` (0 ttl on the live context reads, `cacheHintTtlMs()` elsewhere)
- **Era split — the header's VALUE, never its presence.** `MCP-Protocol-Version` has been REQUIRED on every POST since **2025-06-18**, so a client that negotiated 2025-06-18 or 2025-11-25 through `initialize` sends it on every subsequent request while sending **no** `Mcp-Method`/`Mcp-Name` (those exist only in 2026-07-28) — reading presence as "modern" would `400` every current production client. Value is a modern-era version (`2026-07-28` or later, the spec's own Modern definition) → **modern**, gets the request-metadata validation below. Value == a supported legacy revision → served exactly like a headerless request; that revision defines neither the mirrored headers nor the per-request `_meta` version, so there is nothing to cross-check. **Headerless** → read as a pre-2025-06-18 client, which the spec explicitly permits. Either legacy shape keeps every pre-2026 behaviour: batch works, and any `params._meta["io.modelcontextprotocol/protocolVersion"]` value is tolerated (rejecting one would tell a dual-era client "modern server, don't fall back to `initialize`" about an exchange that never claimed the modern transport)
- **Modern header validation** (`_requestHeader` reads `request.headers`, which the hub exposes case-normalized and List-wrapped over both LAN and the cloud relay): `Mcp-Method` must be present and equal the body `method`; `Mcp-Name` must be present and equal the mirrored body field on the methods that have one — `params.name` on `tools/call`, `params.uri` on `resources/read` (a `=?base64?…?=` sentinel value is decoded first); and a body `params._meta` protocol version, when present, must equal the header. Any of those fails with `400` + `-32020`. A batch body is refused with `400` + `-32600` — the modern transport requires one JSON-RPC message per POST, so an array is a malformed *body*, not a header mismatch. Header *names* compare case-insensitively, header *values* exactly. A notification POST is never header-validated (the spec leaves that undefined) and always answers `202`
- **Unsupported header version → `400` + `-32022`, in BOTH eras** (`data.requested` / `data.supported`, both schema-REQUIRED). 2025-06-18 already mandated 400 for an unsupported `MCP-Protocol-Version`, and `data.supported` is what the client retries from, so this is correct for a legacy client too and cannot wedge one
- **Version negotiation**: `initialize` echoes a requested version that is in `initializeProtocolVersions()` — the supported list MINUS every modern-era version (`2026-07-28` or later) — and otherwise falls back to its head (`2025-11-25`). `initialize` is a legacy-era method (2026-07-28 deleted the handshake), so a request FOR `2026-07-28` negotiates down like any unknown value rather than handing back an era the client reached this method by not speaking. `server/discover` advertises the FULL `supportedProtocolVersions()` list — a client that picks a legacy version off it and sends that header is served correctly and statelessly, since nothing here requires the handshake
- **Origin validation — checked, but 403 enforcement is OPT-IN**: a present `Origin` is compared against the identities the *server* knows for itself (`location.hub.localIP`, `cloud.hubitat.com`, loopback, plus the `additionalAllowedOrigins` Advanced setting), never against the request's own `Host` — in a DNS rebinding attack `Origin` and `Host` both name the attacker's domain, so a self-referential check passes the attacker through. A mismatch is **logged at error level and the request is served** unless the `enforceOriginValidation` toggle is on, a deliberate deviation from the spec MUST: the access token rides in the URL, so a rebound page cannot authenticate and a tokenless request never reaches the handler, while enforcing by default would newly reject reverse-proxy / browser-client setups. Absent `Origin` always passes; malformed reads as invalid
- **Result decoration**: the `_meta` `io.modelcontextprotocol/serverInfo` (name + version) is stamped on **every** result in `jsonRpcResult`, but `resultType: "complete"` is stamped **only on modern-era** results — legacy clients parse an empty result with a STRICT schema (the MCP TypeScript SDK's `EmptyResultSchema` is `ResultSchema.strict()` and rejects unknown keys), so a `resultType` on a legacy `ping` reply breaks the keepalive. `server/discover` sets `resultType` itself, since `DiscoverResult` requires it and discover may arrive headerless. `tools/list` and `server/discover` also carry the `ttlMs` / `cacheScope` cache hints, unconditionally (the legacy `ListToolsResult` schema is passthrough)
- **Notifications**: Handled silently (HTTP 202 Accepted, empty body)
- **Error codes**: `-32700` (parse), `-32600` (invalid request — also the modern batch-body rejection), `-32601` (method not found), `-32602` (invalid params: protocol faults only — unknown tool, malformed `tools/call` envelope, `requestState` faults; a leaf `IllegalArgumentException` is an `isError` tool result instead), `-32603` (internal error), plus `-32020` (HeaderMismatch, modern only) and `-32022` (UnsupportedProtocolVersion, both eras)
- **HTTP status**: application-level JSON-RPC errors ride a **200** body on the legacy path. Non-200: `405` (GET), `403` (invalid Origin), `202` (all-notifications POST), `400` (`-32022` in either era; `-32020` / `-32600` on a modern request), and `404` for an unknown method on a **modern** request — the `-32601` stays in that 404's body, which is what distinguishes it from a legacy HTTP+SSE server's 404. The `-32020` / `-32600` / 404 mappings never apply to a legacy request, and the `-32603` response-size guard drops any derived status when it replaces the body
- **`tools/list` returns the full catalog in one response.** Pagination was tried (page size 50, cursor-based) but removed because many MCP clients — including the Claude.ai connector — don't iterate `nextCursor`, which silently truncated the flat-mode catalog at 50 tools. The full-catalog response is backstopped by the universal response-size guard at `handleMcpRequest` (124,000-byte threshold) that emits a loud `-32603 "Response too large"` envelope if the catalog ever exceeds the hub cap. Stale clients passing a `cursor` get the full catalog and find no `nextCursor`. Opt-in cursor pagination on `tools/call` (`hub_list_devices`, `hub_list_apps`, `hub_list_rules`, etc.) is unaffected.

### Common Pitfalls

1. **Don't forget trailing commas** in `getToolDefinitions()` — it's a Groovy list literal, so every tool definition except the last needs a trailing comma after its closing `]`
2. **Device IDs are strings in MCP but integers internally** — always use `.toString()` for comparison and return values
3. **`state` vs `atomicState`** — use `atomicState` in the child app for rule data (triggers, conditions, actions) and cross-execution state (durationTimers, durationFired, cancelledDelayIds, localVariables); always use read-modify-write pattern for nested maps; use `state` only for UI editor state, counters, and timestamps
4. **Hub properties can throw** — always wrap `hub?.propertyName` in try/catch with `"unavailable"` fallback
5. **Hub internal API responses vary by firmware** — always handle both JSON and non-JSON responses with nested try/catch for parsing
6. **Numeric parsing of API responses** — hub endpoints like `/hub/advanced/freeOSMemory` return text that might not be numeric; wrap `as Integer` / `as Double` conversions in try/catch
7. **OAuth token** — created once in `initialize()` via `createAccessToken()` and stored in `state.accessToken`; never regenerate it or users lose their MCP endpoint URL
8. **Version strings in 9+ locations** — when bumping version, search for the current version string to find all locations across `hubitat-mcp-server.groovy`, `hubitat-mcp-rule.groovy`, `packageManifest.json`, `README.md`, and `SKILL.md`
9. **Date/timestamp parsing** — `formatTimestamp()` tries 6 ISO 8601 format variations (with/without millis, with Z/offset/no timezone, space-separated) to handle differences across firmware versions and upstream APIs. Falls back to truncated raw string if no format matches. Never use a single strict `Date.parse()` format

## Future Plans (Blue-Sky — Needs Research)

These are speculative feature ideas that need feasibility research before implementation. When the user asks "what should I work on next?" or similar, reference this list.

### HPM Integration
- Search HPM repositories for packages by keyword
- Install/uninstall packages via HPM programmatically
- Check for updates across all installed packages

### App/Integration Discovery (Outside HPM)
- Search for and install official Hubitat integrations not yet enabled
- Discover and install community apps/drivers from GitHub, forums, etc.

### Dashboard Management
- Create, modify, delete dashboards programmatically
- Prefer official Hubitat dashboards (home screen + mobile app visibility)
- If official API isn't available, explore alternatives that can be set as defaults

### Rule Machine Interoperability
- Read native RM rules via their export/import/clone UI mechanism (API unknown)
- Export MCP rules in RM-importable format
- Import directly into native Rule Machine if API exists
- Bidirectional sync between MCP rules and RM rules (long-shot)

### Additional Ideas
- Device creation — `hub_manage_virtual_device` creates MCP-owned children; `hub_create_device` creates standalone devices through the native hub UI endpoint. Choose the ownership model before creation.
- Device pairing assistance (Z-Wave inclusion, Zigbee pairing, cloud device setup)
- Notification/alert management (granular routing)
- Scene management (create/modify/manage beyond activate_scene)
- Energy monitoring aggregation and reports
- Scheduled automated reports (hub health, device status, rule history)