{ "packageName": "MCP Rule Server", "author": "kingpanther13", "version": "0.10.1", "minimumHEVersion": "2.3.0", "dateReleased": "2026-04-24", "documentationLink": "https://github.com/kingpanther13/Hubitat-local-MCP-server/blob/main/README.md", "communityLink": "", "releaseNotes": "v0.10.1 - test(server): unit-test manage_destructive_hub_ops / manage_apps_drivers / manage_app_driver_code gateways: …ers / manage_app_driver_code gateways. Closes #73. New specs cover every tool in the three gateways, golden-path + error-path per tool: reboot_hub, shutdown_hub, delete_device, list_hub_apps, list_hub_drivers, get_app_source, get_driver_source, list_item_backups, get_item_backup, install_app, install_driver, update_app_code (source / sourceFile / resave variants), update_driver_code, delete_app, delete_driver, restore_item_backup. Every destructive tool has explicit confirm and Hub-Admin-Write gate tests. Fixes a GString/String map-key bug in toolDeleteItem and toolUpdateItemCode that the new tests uncovered. backupItemSource stores manifest entries using subscript assignment (state.itemBackupManifest[key] = manifest) which coerces the GString key to String. The cleanup paths in toolDeleteItem (on delete success) and toolUpdateItemCode (on update success) looked up that entry with an explicit .get(\"\\${type}_\\${itemId}\") / .remove(\"\\${type}_\\${itemId}\") — explicit .get(GString) / .remove(GString) do NOT coerce, and HashMap hashes GString and String differently, so the lookup/removal silently no-op'd. The visible effects were delete_app / delete_driver always returning backupFile: null + restoreHint: null, and update_app_code / update_driver_code leaving stale manifest entries that list_item_backups would then still show after a successful update. Fix is a .toString() at both call sites / test(server): unit-test manage_logs / manage_diagnostics / manage_files gateways: …es gateways. Closes #74. New specs cover every tool in the three gateways, golden-path + error-path per tool across 23 tools:. manage_logs (8): get_hub_logs (expanded: level filter per-level, source substring, limit trim, empty-response), get_device_history, get_performance_stats (sortBy variants, type filtering), get_hub_jobs, get_debug_logs (level/ component/limit filters), clear_debug_logs, set_log_level, get_logging_status. manage_diagnostics (11): get_set_hub_metrics (CSV snapshot write, low-memory warning, recordSnapshot=false skip), get_memory_history (CSV parsing, summary stats, limit truncation, empty-response), force_garbage_collection (before/ after deltas, non-numeric probe handling), device_health_check (healthy/ stale/unknown classification, includeHealthy toggle), get_rule_diagnostics (via childAppAccessor fixture), get_zwave_details / get_zigbee_details (sdk_only fallback + hub_api_raw + hub_api branches), zwave_repair (confirm + Hub Admin Write gate + POST success/failure), list_captured_states (sorted newest-first, at-capacity warning), delete_captured_state (not-found path), clear_captured_states. manage_files (4): list_files (JSON + HTML fallback + API-unavailable), read_file (chunk boundaries, offset+length, missing-file), write_file (confirm gate, Hub Admin Write gate, missing-backup gate, invalid filenames, existing-file backup, upload failure), delete_file (confirm gate, skip-backup-of-backup, delete failure). Adds TestLocation support fixture. Hubitat's real Location exposes a magic \\`hub\\` property that eighty20results' Location interface doesn't declare, so @AutoImplement on a concrete class with a settable \\`hub\\` field gives tests a clean way to drive \\`location.hub.zwaveVersion\\` / \\`location.hub.uptime\\` reads without reimplementing the 20+ Location methods. Two @Shared stubs layered on the AppExecutor mock per spec: - ToolManageLogsSpec: getApp() -> TestChildApp (for toolSetLogLevel's app.updateSetting via the @Delegate chain). - ToolManageDiagnosticsSpec: getLocation() -> TestLocation (per-test hub mutation; cleanup() resets between tests). clear_captured_states / delete_captured_state are not gated on confirm=true in the current server source (dispatch is direct through clearAllCapturedStates / deleteCapturedState). Tests pin existing no-confirm behaviour; issue #74 listed them under confirm gating but adding a gate would be a separate design change / PR #75 / test(rules): breadth coverage for conditions, actions, triggers, loop guard, error paths (closes #75): Prep for #75's breadth specs: - TestLocation gains mutable mode/sunrise/sunset/hsmStatus fields plus a setMode() call log so specs can exercise `mode` / `sun_position` / `hsm_status` conditions and the `set_mode` action without closure-Map tricks. The hsmStatus field isn't on HubitatCI's Location interface, but Groovy property dispatch resolves it on the concrete object (real Hubitat exposes it the same way). - RuleHarnessSpec now shares a single TestLocation instance across the spec class, and setup() resets its mutable fields so per-test state doesn't leak between features. - TestDevice grows the command methods the rule engine's action types dispatch to (setColor, setColorTemperature, lock/unlock, deviceNotification, thermostat setters, speak, valve/fan/shade, plus hasCapability/hasCommand). Each delegates to invokeCommand so Spy(TestDevice) { 1 * setColor(_) } still works. No rule-engine production code changes. Existing primitive specs keep their behaviour — the harness changes are additive / test: backfill regression specs from CHANGELOG / release-notes history (closes #76): …y (#76). Adds two regression spec files — one server-side, one rule-engine — that pin behaviour restored by specific historical fixes so future refactors can't silently reintroduce the original bugs. Server regressions (src/test/groovy/server/RegressionsFromHistorySpec.groovy): - v0.7.7 formatAge singular/plural grammar across minute/hour/day + \"just now\" + null/zero fallback - v0.6.1 BigDecimal.round() crash in checkForUpdate skip-branch (the \"nightly 3 AM crash\"): calling with a fresh-enough checkedAt no longer throws - v0.5.3/5.4/7.6 device_health_check hoursAgo formula: one-decimal fractional hours, not 10x off, no BigDecimal.round - v0.8.5 get_hub_logs source filter now matches against the message field (previously compared against timestamp, silently missing every real source search) - v0.5.1 get_hub_logs falls back to newline-split on a non-JSON response - v0.8.2 normalizeCommandParams parses a JSON-string element into a Map (setColor regression) - v0.8.5 normalizeCommandParams brace-matches an embedded JSON object out of the raw-String wrapper Hubitat hands through when its parser chokes on nested JSON - convertParamElements numeric coercion for Integer + Double. Rule regressions (src/test/groovy/rules/RegressionsFromHistorySpec.groovy): - v0.1.6 repeat action honours the legacy `count` parameter as a fallback for `times` - v0.1.22 action-return semantics: a `stop` inside a `repeat` halts all remaining iterations AND prevents subsequent outer actions - v0.7.6 substituteVariables `%now%` resolves to a timestamp even when atomicState.localVariables holds a key named `now` (the shadow fix) - v0.7.7 evaluateConditions fail-closed when an evaluator throws (repeated here with an explicit release-note citation so the backfill trail is self-contained; aggregator already pinned by EvaluateConditionsSpec). The rule-engine spec's class Javadoc enumerates which historical regressions are NOT duplicated here because an existing breadth spec (ConditionTypesSpec, EvaluateConditionsSpec, ActionTypesSpec, TriggerBreadthSpec) already pins them, with pointers. Scope note on sandbox-only bugs: log.isDebugEnabled() not available in the hub sandbox (v0.8.2), Date.format(String, Locale) not available (v0.8.6), and getClass()/Eval.me restrictions (SANDBOX-001) cannot be asserted at the unit level — the harness runs with Flags.DontRestrictGroovy and a PermissiveLog. Those stay guarded by sandbox_lint.py at CI lint time; see PR #103 and PR #107 for the rationale / PR #76 / Add get_app_config + list_app_pages (manage_installed_apps gateway): New core tool (on tools/list) that reads an installed app's config via the hub's SDK-level rendering endpoint /installedapp/configure/json/[/]. Works for Rule Machine rules, Room Lighting instances, Basic Rules, Hubitat Package Manager, Mode Manager, Button Controllers, and any other legacy SmartApp — same endpoint the Hubitat Web UI itself consumes for every config page. Returns a normalized structure: - app: identity (id, label, name, appType summary, disabled, parentAppId, installed) - page: {name, title, install, refreshInterval, sections: [{title, inputs, paragraphs}]} where each input has {name, type, title, description?, multiple?, required?, options?, value?} - childApps: [{id, label, name}] summary - endpoint: the actual URL hit (for debugging) - settingsKeyCount: size of the raw settings map - settings: only included when includeSettings=true (large apps have 500-1000 keys with app-specific encoding like Room Lighting's 'dm~~' — opt-in for power users who want to decode them). Multi-page apps (Rule Machine 5.1, HPM) expose sub-pages by name via the pageName arg — e.g. get_app_config(appId=35, pageName=\"prefPkgModify\") returns HPM's installed-packages list. Runtime fingerprint check: the tool asserts the top-level invariants it depends on (app object, configPage object, configPage.sections array). If Hubitat firmware drifts the endpoint contract, callers see a clear error pointing at the specific shape mismatch, not malformed data. Paired with a new standalone audit script (scripts/app_config_audit.py) that can be run manually or in CI to pre-emptively detect drift across known app IDs before users hit it. Other additions: - New 'App Introspection' core-tools section in README, TOOL_GUIDE.md, and the agent-skill references. Tool counts updated (21→22 core, 69→70 total, 30→31 on tools/list). - scripts/audit_config.example.json — sample target list for the audit script. - Helper stripAppConfigHtml() to clean Hubitat's color-span HTML from labels/titles in user-facing fields. Gate: requireHubAdminRead() — same tier as existing read-only admin endpoints (/hub2/appsList, /device/fullJson/, etc.). Sandbox lint clean. Live-tested on firmware 2.4.4.156 against Room Lighting, Rule Machine 5.0/5.1, and HPM — including error paths (non-numeric appId, unknown appId, invalid pageName chars, HPM sub-page navigation, includeSettings=true). Audit script verified against the same five targets. Read-only — no write path in this PR. Write support (creating/modifying app config) would be a separate opt-in, deliberately out of scope for initial introduction / test: backfill sunrise/sunset silent-failure fix + broader silent-device-not-found coverage (#76): …roader silent-device-not-found coverage (#76). Two backfill gaps from the first regression PR (#116) — both for bugs that shipped \"silent,\" meaning no exception, no log, no user-visible signal. Exactly the class of regression hardest to catch without dedicated tests. Adds src/test/groovy/server/NormalizeTriggerSpec.groovy. Covers hubitat-mcp-server.groovy::normalizeTrigger (commit 2a6da11, v0.2.12 follow-up). Pre-fix: the rule engine only recognised the canonical sunrise/sunset trigger shape {type:'time', sunrise:true}, while create_rule / update_rule accepted four additional LLM-natural shapes (e.g. {type:'sunrise'}, {type:'time', time:'sunrise'}). Rules saved under those shapes silently never fired — no log, no error, no hint. normalizeTrigger is the bridge that converts every accepted input shape to the canonical form before persistence. Tests cover: - All five input shapes from the original bug report convert correctly - Canonical {type:'time', sunrise:true} passes through unchanged - Unrelated trigger types (device_event) are not mutated - offsetMinutes is renamed to offset; an existing canonical offset wins over a legacy offsetMinutes on the same input - Return value is a fresh map — mutating the output does not affect the caller's input (defends the new LinkedHashMap(trigger) clone). Extends src/test/groovy/rules/RegressionsFromHistorySpec.groovy. Adds a @Unroll feature method covering 8 action types from commit 83aee5b's \"Comprehensive debug logging overhaul - eliminate silent failures\": set_level, set_color, toggle_device, lock, unlock, speak, send_notification, set_thermostat. Before 83aee5b each would NPE on a missing-device method call, get swallowed by the outer try/catch, and silently skip with no log. The fix added an explicit `if (device) { ... } else { ruleLog(\"warn\", ...) }` branch at each call site. For each type the test asserts: - a warn-level ruleLog is emitted citing both the action type and the missing device id - the NEXT action in the chain still fires — no silent throw stopped execution. ErrorPathsSpec already pinned the device_command slice of this regression. This extends that guard to the rest of the action types the 83aee5b commit named. activate_scene / set_valve / set_fan_speed / set_shade / set_color_temperature use the same shape and are implicitly covered by the same fix; adding them would be pure repetition / PR #77 / test(integration): in-harness dispatch drive-through for handleMcpRequest + subscribe/fire (#77): …fire (#77). Adds two seams to the unit-test harness that production code relies on but existing specs don't exercise, alongside two specs that prove the scaffolding works. **Server: `handleMcpRequest()` HTTP pipeline** (`McpRequestDriver`) - `request.JSON` reads and `render(Map)` writes round-trip through a per-test driver so specs can POST a JSON-RPC body and assert on the captured envelope (status / contentType / parsed response). - `request` is installed via reflection into HubitatAppScript's private `injectedMappingHandlerData` Map — the class's `@CompileStatic` `getProperty(String)` override short-circuits the name `request` to that field before MOP dispatch, so a metaClass hook on the script is never consulted. - `render(Map)` is stubbed via the `setupSpec` dispatcher pattern already documented as class-2 in the dispatch cheat sheet. - Covers: initialize / tools/list / tools/call happy paths, notifications (204 no-content), batch requests, empty-batch -32600, null-body -32700, unknown-method -32601, and invalid-request -32600 (missing jsonrpc marker). **Rule engine: `subscribe()` + event replay** (`SubscriptionRecorder`) - Records every `subscribe(source, attribute, handlerName)` call and exposes `fireEvent(script, source, attribute, value)` to replay a synthetic event back at the recorded handler. - `AppSubscriptionReader` validates that the subscribe source is a `DeviceWrapper` before the Mock stub can fire — `RuleHarnessSpec` adds `Flags.DontValidateSubscriptions` to the `PassThroughAppValidator` to let plain `TestDevice` POJOs through. - Covers: single-device + multi-device + mode_change wire-up, end-to-end handler→action dispatch on a matching event, non-matching value no-op, and a loud failure mode when `fireEvent` can't find a subscription. **Docs** - New \"E2E drive-through\" section in `docs/testing.md` explains the wiring, includes spec patterns, and flags the trigger.deviceId-must-be- String gotcha that broke the initial draft of the rule spec. Part of #77 — in-harness drive-through. The literal-fake-hub HTTP-server variant (#77's stretch read) can build on this; this PR delivers the 90% of value that doesn't need its own process. Full suite: 606 tests, 0 failures / fix(release): push via deploy key to bypass main-branch ruleset: The release workflow runs on push to main after a labeled PR merges, generates a chore(release): version-bump commit locally on the runner, and pushes it to main. Since the commit is brand-new on the runner, the required status checks (test + guard) have never run on it, and main-branch protection rejects the push. For user-owned repos GitHub only allows DeployKey / RepositoryRole / Team actors in a ruleset's bypass list — Integration (GitHub App) bypass is org-only. A new repo-wide deploy key `release-bot (ruleset bypass)` now has write access and is listed as a bypass actor on the `main-required-checks` ruleset. Private half lives in the repo secret RELEASE_DEPLOY_KEY. Swap `token: GITHUB_TOKEN` for `ssh-key: RELEASE_DEPLOY_KEY` on the checkout step so actions/checkout configures the origin as SSH and loads the deploy key into ssh-agent; subsequent `git push origin main` and `git push origin v` then push as the deploy key and bypass cleanly. Old-style branch protection deleted; the ruleset is now the sole enforcement mechanism on main (still requires test + guard for every human PR).\n\nv0.10.0 - docs: re-collapse Future Plans + refresh MCP tools list: Commit 51b6c3a (Apr 20 auto-sync) stripped the
wrapper around the Future Plans section, so the speculative backlog now dominates the rendered README. Wrap the section back in
and update the sync workflow so future auto-syncs preserve the collapse. While in the file, refresh the MCP tools catalog to match the current Groovy source: - 74 total / 31 on tools/list / 22 core / 52 gateway subtools - add search_tools (v0.9.1) under Reference - manage_logs grew to 8: add get_performance_stats + get_hub_jobs - manage_diagnostics grew to 11: add get_memory_history + force_garbage_collection / build(deps): bump the gradle-dependencies group with 2 updates (#101 (https://github.com/kingpanther13/Hubitat-local-MCP-server/pull/101), @app/dependabot): Bumps the gradle-dependencies group with 2 updates: [org.quartz-scheduler:quartz](https://github.com/quartz-scheduler/quartz) and [su.litvak.chromecast:api-v2](https://github.com/vitalidze/chromecast-java-api-v2). Updates `org.quartz-scheduler:quartz` from 2.3.0 to 2.5.2 - [Release notes](https://github.com/quartz-scheduler/quartz/releases) - [Commits](https://github.com/quartz-scheduler/quartz/compare/quartz-2.3.0...v2.5.2). Updates `su.litvak.chromecast:api-v2` from 0.11.1 to 0.11.3 - [Commits](https://github.com/vitalidze/chromecast-java-api-v2/compare/v0.11.1...v0.11.3). --- updated-dependencies: - dependency-name: org.quartz-scheduler:quartz dependency-version: 2.5.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle-dependencies - dependency-name: su.litvak.chromecast:api-v2 dependency-version: 0.11.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: gradle-dependencies .... Signed-off-by: dependabot[bot] / fix(lint): scan GString interpolations for sandbox violations: sandbox_lint.py stripped all string contents before running regex rules, which meant violations inside GString `${...}` interpolations (which are evaluated as Groovy at runtime) were silently ignored. Found during PR #79 review: `\"...(type=${rawKey?.getClass()?.simpleName})...\"` passed lint but would raise SecurityException on a real hub. - Preserve `${...}` bodies when stripping double-quoted and triple-double- quoted strings; blank literal text only. - Handle nested braces inside interpolations (closures, method chains). - Leave single-quoted strings fully blanked (Groovy single-quotes aren't GStrings). - Add `--self-test` mode with 8 fixture cases covering the common shapes: interpolated getClass/Locale hit; plain-literal and single-quoted getClass miss; escaped `\\${` does not open an interpolation. - Wire --self-test into the sandbox-lint CI workflow as a pre-scan step / perf(test): cache HubitatAppSandbox parse per spec class (5m → 1.5m): Before this change, HarnessSpec.setup() and RuleHarnessSpec.setup() ran before every Spock feature method and rebuilt the sandbox from scratch: read the 8000-line hubitat-mcp-server.groovy (or 4000-line hubitat-mcp-rule.groovy) from disk, AST-validate it through HubitatCI, compile it in the Groovy sandbox, and bootstrap the script. That was ~3 seconds of pure parse/compile per test. With 81 tests on main (and ~160 on the feature branches that add tests) the per-test re-parse dominated CI runtime — the unit-tests job was taking 7 minutes on PR #79 and 5 minutes on a fresh main, almost none of it spent in actual test logic. Approach: - Move `new HubitatAppSandbox(...)` / `sandbox.run(...)` into setupSpec, stored on @Shared fields so the compiled script is reused across every feature method in a spec class. - Keep fixture collections (stateMap, atomicStateMap, settingsMap, childDevicesList, childAppsList) as @Shared `final` references so the AppExecutor mock's stubs (also built in setupSpec) continue to see them. setup() clears contents between tests — no reassignment, so the captured references stay valid. - Leave wireScriptOverrides / wireOverrides in setup() rather than setupSpec. They rewire metaClass hooks and reflective field sets on the shared script each test, so subclasses can override them with closures that capture the current feature instance's non-@Shared fields (e.g. SubstituteVariablesSpec.globalVars). Cheap compared to the parse cost, and keeps the extension contract intuitive. - HubInternalGetMock gains a reset() that clears handlers + calls, so the shared instance is safe to reuse across tests. - RuleHarnessSpec: _parent stays per-feature; setup() explicitly resets it and calls script.setParent(null) to cancel out any previous test's state on the shared script. Observed locally (JDK 11, fresh daemon, --rerun-tasks, 81 tests): - Before: 5m 7s wall / 289.9s of test time / ~3.5s per test - After: 1m 36s wall / 70.6s of test time / ~0.9s per test (69% wall-clock reduction, 76% test-time reduction). Per-spec, the flat base cost is now one parse per Spec class (~4-6s) plus near-zero per feature method — so adding more tests to an existing spec is essentially free, and the 7m CI job on PR #79 should drop into the 1.5-2m range. Also in this PR: - testLogging.events now includes 'started', so future harness regressions surface per-test in the CI log instead of leaving the runner silent for minutes (the original 7m job showed zero test events between JVM start and finish). - unit-tests.yml uploads build/reports/tests/test and build/test-results/test unconditionally (not just on failure), so per-spec time=\"...\" data is available for perf regression tracking. No subclass spec changes required — the protected HarnessSpec / RuleHarnessSpec API (script, appExecutor, stateMap, etc.) is preserved / Built-in app visibility + Rule Machine interop (2 new gateways, 7 tools): Adds two new gateways giving the AI visibility into Hubitat's built-in apps and a read/trigger surface on Rule Machine. Closes a large functional gap — prior to this PR the AI had no way to see what Rule Machine rules, Room Lighting instances, Scenes, Mode Manager schedules, or dashboards existed on the hub, or which of them referenced a given device. - `list_installed_apps(filter?, includeHidden?)`: Enumerates every app on the hub — built-in system apps + user-installed — with parent/child tree. Backed by /hub2/appsList (the same undocumented-but-stable JSON endpoint the Hubitat Web UI uses for its Apps page). Filters: all / builtin / user / disabled / parents / children. Hidden child apps suppressed by default and re-parented to the nearest visible ancestor so parentId references never orphan. - `get_device_in_use_by(deviceId)`: Reverse lookup — given a device, lists the apps that reference it (Room Lighting, Rule Machine, Groups, Mode Manager, dashboards, Maker API, etc.). Backed by /device/fullJson/ which already exposes appsUsing + appsUsingCount. Answers \"if I delete this device, which automations break?\". Uses the official hubitat.helper.RMUtils helper class — the community- documented entry point that RM's own companion apps use. Read + trigger only; CANNOT create, modify, or delete RM rules: Hubitat's platform blocks addChildApp parent-type validation for third-party apps. Documented prominently so the AI refuses invalid requests rather than fabricating fake tools. - `list_rm_rules`: Enumerate RM 4.x + 5.x rules (combined, deduplicated by id), id + label for each. Handles both RM shapes: the documented `[{id, label}]` and the undocumented RM 5.x single-entry-Map shape `[:\"