feat: verify household contract and document secure Grocy setup
This commit is contained in:
@@ -7,3 +7,114 @@ Household records cover products/barcodes/groups, locations, units/conversions,
|
||||
Account mutation, API keys, sessions, permissions, configuration secrets, public sharing links, external lookup and printing are excluded. Stock-unit changes require a positive unambiguous Grocy conversion. Grocy 4.7.1's database cascade rescales stock and related records; its API result is read back. These preflight checks do not prevent simultaneous edits by other clients.
|
||||
|
||||
Source: [Grocy API specification](https://github.com/grocy/grocy/blob/master/grocy.openapi.json).
|
||||
|
||||
## Household entity inventory
|
||||
|
||||
Use `records_describe` for required creation fields, then `records_list`/`records_lookup` to find explicit IDs. `records_create`, `records_update` and `records_delete` are limited to editable entities. `userfields_get`/`userfields_update` read/change defined household values. For a custom object use `entity: userobjects` and its object ID; the MCP resolves its custom entity from the stored definition. Define its fields against `userentity-<name>` after creating that household entity. Account custom-field definitions are filtered from reads and cannot be mutated. For stock batch values, pass the numeric entry row ID plus an exact `stock_transaction_id` from a purchase/inventory-correction/entry-edit receipt or history. Grocy inserts these fields via transactions; reads resolve the batch ID automatically. A transaction spanning other batch IDs is refused.
|
||||
|
||||
| Entity | Access |
|
||||
| --- | --- |
|
||||
| `products` | Read/create/update/delete |
|
||||
| `chores` | Read/create/update/delete |
|
||||
| `product_barcodes` | Read/create/update/delete |
|
||||
| `batteries` | Read/create/update/delete |
|
||||
| `locations` | Read/create/update/delete |
|
||||
| `quantity_units` | Read/create/update/delete |
|
||||
| `quantity_unit_conversions` | Read/create/update/delete |
|
||||
| `shopping_list` | Read/create/update/delete |
|
||||
| `shopping_lists` | Read/create/update/delete |
|
||||
| `shopping_locations` | Read/create/update/delete |
|
||||
| `recipes` | Read/create/update/delete |
|
||||
| `recipes_pos` | Read/create/update/delete |
|
||||
| `recipes_nestings` | Read/create/update/delete |
|
||||
| `tasks` | Read/create/update/delete |
|
||||
| `task_categories` | Read/create/update/delete |
|
||||
| `product_groups` | Read/create/update/delete |
|
||||
| `equipment` | Read/create/update/delete |
|
||||
| `userfields` | Read/create/update/delete |
|
||||
| `userentities` | Read/create/update/delete |
|
||||
| `userobjects` | Read/create/update/delete |
|
||||
| `meal_plan` | Read/create/update/delete |
|
||||
| `stock_log` | Read only |
|
||||
| `stock` | Read only |
|
||||
| `stock_current_locations` | Read only |
|
||||
| `chores_log` | Read only |
|
||||
| `meal_plan_sections` | Read/create/update/delete |
|
||||
| `products_last_purchased` | Read only |
|
||||
| `products_average_price` | Read only |
|
||||
| `quantity_unit_conversions_resolved` | Read only |
|
||||
| `recipes_pos_resolved` | Read only |
|
||||
| `battery_charge_cycles` | Read only |
|
||||
| `product_barcodes_view` | Read only |
|
||||
| `uihelper_shopping_list` | Read only |
|
||||
|
||||
## Named upstream operations
|
||||
|
||||
All routes below are relative to the enrolled API base. Local descriptions/validation and credential loading do not make HTTP requests. Generic record tools use `/objects/{entity}` or `/objects/{entity}/{id}`; custom values use `/userfields/{entity}/{id}`. An unavailable server route/record returns `NOT_FOUND`, rejected inputs/permissions return sanitized errors, and an interrupted mutation can return `UNKNOWN_WRITE_OUTCOME`.
|
||||
|
||||
| Tool | Method | API route |
|
||||
| --- | --- | --- |
|
||||
| `database_changed_time` | GET | `/system/db-changed-time` |
|
||||
| `server_time` | GET | `/system/time` |
|
||||
| `stock_list` | GET | `/stock` |
|
||||
| `stock_entry` | GET | `/stock/entry/{entryId}` |
|
||||
| `stock_entry_put` | PUT | `/stock/entry/{entryId}` |
|
||||
| `stock_volatile` | GET | `/stock/volatile` |
|
||||
| `stock_product` | GET | `/stock/products/{productId}` |
|
||||
| `stock_locations` | GET | `/stock/products/{productId}/locations` |
|
||||
| `stock_entries` | GET | `/stock/products/{productId}/entries` |
|
||||
| `stock_price_history` | GET | `/stock/products/{productId}/price-history` |
|
||||
| `stock_add` | POST | `/stock/products/{productId}/add` |
|
||||
| `stock_consume` | POST | `/stock/products/{productId}/consume` |
|
||||
| `stock_transfer` | POST | `/stock/products/{productId}/transfer` |
|
||||
| `stock_inventory` | POST | `/stock/products/{productId}/inventory` |
|
||||
| `stock_open` | POST | `/stock/products/{productId}/open` |
|
||||
| `stock_copy` | POST | `/stock/products/{productId}/copy` |
|
||||
| `stock_merge` | POST | `/stock/products/{productIdToKeep}/merge/{productIdToRemove}` |
|
||||
| `stock_product_by_barcode` | GET | `/stock/products/by-barcode/{barcode}` |
|
||||
| `stock_add_by_barcode` | POST | `/stock/products/by-barcode/{barcode}/add` |
|
||||
| `stock_consume_by_barcode` | POST | `/stock/products/by-barcode/{barcode}/consume` |
|
||||
| `stock_transfer_by_barcode` | POST | `/stock/products/by-barcode/{barcode}/transfer` |
|
||||
| `stock_inventory_by_barcode` | POST | `/stock/products/by-barcode/{barcode}/inventory` |
|
||||
| `stock_open_by_barcode` | POST | `/stock/products/by-barcode/{barcode}/open` |
|
||||
| `stock_location_entries` | GET | `/stock/locations/{locationId}/entries` |
|
||||
| `shopping_add_missing_products` | POST | `/stock/shoppinglist/add-missing-products` |
|
||||
| `shopping_add_overdue_products` | POST | `/stock/shoppinglist/add-overdue-products` |
|
||||
| `shopping_add_expired_products` | POST | `/stock/shoppinglist/add-expired-products` |
|
||||
| `shopping_clear` | POST | `/stock/shoppinglist/clear` |
|
||||
| `shopping_add_product` | POST | `/stock/shoppinglist/add-product` |
|
||||
| `shopping_remove_product` | POST | `/stock/shoppinglist/remove-product` |
|
||||
| `stock_booking` | GET | `/stock/bookings/{bookingId}` |
|
||||
| `stock_undo_booking` | POST | `/stock/bookings/{bookingId}/undo` |
|
||||
| `stock_transaction` | GET | `/stock/transactions/{transactionId}` |
|
||||
| `stock_undo_transaction` | POST | `/stock/transactions/{transactionId}/undo` |
|
||||
| `recipe_add_not_fulfilled_products_to_shoppinglist` | POST | `/recipes/{recipeId}/add-not-fulfilled-products-to-shoppinglist` |
|
||||
| `recipe_fulfillment` | GET | `/recipes/{recipeId}/fulfillment` |
|
||||
| `recipe_consume` | POST | `/recipes/{recipeId}/consume` |
|
||||
| `recipes_fulfillment` | GET | `/recipes/fulfillment` |
|
||||
| `recipe_copy` | POST | `/recipes/{recipeId}/copy` |
|
||||
| `chores_list` | GET | `/chores` |
|
||||
| `chore_details` | GET | `/chores/{choreId}` |
|
||||
| `chore_execute` | POST | `/chores/{choreId}/execute` |
|
||||
| `chore_undo_execution` | POST | `/chores/executions/{executionId}/undo` |
|
||||
| `chores_recalculate_assignments` | POST | `/chores/executions/calculate-next-assignments` |
|
||||
| `chore_merge` | POST | `/chores/{choreIdToKeep}/merge/{choreIdToRemove}` |
|
||||
| `batteries_list` | GET | `/batteries` |
|
||||
| `battery_details` | GET | `/batteries/{batteryId}` |
|
||||
| `battery_charge` | POST | `/batteries/{batteryId}/charge` |
|
||||
| `battery_undo_charge` | POST | `/batteries/charge-cycles/{chargeCycleId}/undo` |
|
||||
| `tasks_list` | GET | `/tasks` |
|
||||
| `task_complete` | POST | `/tasks/{taskId}/complete` |
|
||||
| `task_undo` | POST | `/tasks/{taskId}/undo` |
|
||||
| `file_get` / `file_upload` / `file_delete` | GET / PUT / DELETE | `/files/{group}/{base64Filename}` |
|
||||
| `calendar_export` | GET | `/calendar/ical` |
|
||||
| `shopping_export` | GET | `/objects/shopping_list` (filter explicit list ID) |
|
||||
| `assignment_users` | GET | `/users` (return only IDs/display names) |
|
||||
|
||||
File groups: `equipmentmanuals`, `recipepictures`, `productpictures`, `userfiles`; avatars are excluded. Stock log/history views remain read-only, while supported booking/transaction undo has named action tools. Recipe-derived shopping additions use Grocy's API default list; the endpoint has no explicit list selector. This server does not invent one.
|
||||
|
||||
## Explicit exclusions
|
||||
|
||||
No arbitrary API request tool. No system configuration/user-settings secrets, API-key/session/permission administration, user/account mutation, public sharing-link routes, barcode external lookup, printer/webhook endpoints or attachments outside `/api`. Assignment labels are the only account projection. Server-configured hooks can still run as part of Grocy's own household policies.
|
||||
|
||||
The catalog covers the reviewed household API surface, not undocumented plugins, server internals, database maintenance, filesystem or orphan-file enumeration. The Docker contract exercises a meaningful scenario per domain; it does not claim every possible parameter combination or server-version behavior has been tested.
|
||||
|
||||
27
docs/compatibility.md
Normal file
27
docs/compatibility.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Compatibility and verification
|
||||
|
||||
Baseline: **Grocy 4.7.1**, Linux desktop, official Rust MCP SDK (`rmcp` 1.8.0 resolved in Cargo.lock). Household field definitions were reviewed from that release's database schema and API specification because its OpenAPI omits several schemas and mislabels some numeric path IDs. No assumption is made that future server fields are safely editable: update the catalog deliberately.
|
||||
|
||||
The disposable contract pins the LinuxServer image:
|
||||
|
||||
```
|
||||
lscr.io/linuxserver/grocy@sha256:3c1ce96cb2d0f0c7ea0c2e0b89f5769be7f2f102204fbeef87aeef56afd6036e
|
||||
```
|
||||
|
||||
The scenario uses actual MCP SDK discovery/calls and synthetic credentials supplied only by test code. A frontend-blocking proxy accepts `/api/…` and `/household/api/…`; setup/doctor and workflows made zero requests outside those prefixes. It covers product/unit/location creation and updates, real inventory, stock-entry editing, opening/transfer/consumption/transaction undo, inventory, conversion and stock-unit cascade, shopping add/remove/clear/export, recipe ingredients/nesting/copy/consumption/undo, meal plans/sections, chore execution/undo, task completion/undo/rescheduling, battery charging/undo, equipment/custom definitions/custom-entity and stock-batch values, account-definition read/write exclusion, a PDF upload/readback/download/delete, iCalendar export, record deletion and read-only rejection.
|
||||
|
||||
The small default suite separately checks malformed/traversing destinations, real self-signed TLS rejection, redirects, compression/size caps, interrupted write without replay, plaintext Secret Service refusal against isolated D-Bus, provider identity mismatch, preference ownership/modes/symlinks, field/quantity/name validation, required conversion preflight, MCP result equivalence/annotations, grouped results, successful writes with failed readback, file bytes and setup cancellation/recovery/configuration preservation. The actual desktop wallet's encrypted success path and interactive real enrollment require local use; synthetic tests do not claim to have enrolled a real wallet or household account.
|
||||
|
||||
Final delivery checks use formatting, Clippy with warnings denied, the focused tests, the explicit Docker contract and a release build. The one-time RustSec check against the current advisory database found no reported vulnerable locked dependency (290 dependencies, 2026-10-01). This is a time-specific advisory result, not proof of absence of vulnerabilities.
|
||||
|
||||
## Practical limits
|
||||
|
||||
- Other Grocy versions may reject individual routes/fields or need migration. Errors are explicit; no frontend fallback or fabricated writes are performed. Broad server permissions may be required for custom-field/entity administration even when ordinary household actions are allowed.
|
||||
- Readback confirms a retrieved state, not an atomic conditional revision. Grocy can compact stock entries and change their IDs; an entry readback can become unavailable after a successful edit.
|
||||
- Recipe consumption returns HTTP 204 and can consume only available ingredients. The MCP reports the empty receipt honestly. Read stock-log records to identify the precise transaction before undo; do not guess during concurrent writes.
|
||||
- Files require their known group/filename (from records/custom fields). Grocy has no API to enumerate orphan files. The MCP has no host-path or remote-URL import interface.
|
||||
- Unpaginated upstream endpoints must fit the 2 MiB complete-response bound before local paging. Very large households may need future server-side filtering support. Generic list tools use server pagination where supported; their metadata indicates whether another page exists.
|
||||
- The private profile contains enrollment metadata samples (up to 100 IDs/names for locations/units/lists, with bounded name lengths); these are setup defaults, not an authoritative live cache. Tool calls read live data.
|
||||
- Encrypted IPC and provider binding have been tested negatively with an isolated incompatible service. KWallet storage-password strength remains a user/native-manager check. See the setup and security documents.
|
||||
|
||||
Primary references: [Grocy API](https://github.com/grocy/grocy/blob/master/grocy.openapi.json), [Grocy source](https://github.com/grocy/grocy), [LinuxServer image documentation](https://docs.linuxserver.io/images/docker-grocy/), [Rust MCP SDK](https://github.com/modelcontextprotocol/rust-sdk), [Secret Service specification](https://specifications.freedesktop.org/secret-service-spec/latest/).
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hermes Grocy MCP Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a lightweight Rust stdio MCP that gives Hermes useful household reads and writes through Grocy's API, with protected desktop-wallet enrollment and understandable setup.
|
||||
|
||||
@@ -55,7 +55,7 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
|
||||
**Interfaces:** `ApiBase::parse(input: &str, policy: TransportPolicy) -> Result<ApiBase>`; `ApiBase::route(template: &'static str, params: &BTreeMap<String, String>) -> Result<Url>`; `ApiClient::new(profile: PrivateProfile) -> Result<ApiClient>`; `async ApiClient::request(&self, request: ApiRequest) -> Result<ApiResponse>`. Internal `ApiRequest` contains a static reviewed route template, `HttpMethod`, separately encoded path/query parameters, `RequestBody::{Empty, Json(Value), Binary(Vec<u8>)}`, and `ResponseKind::{Json, Text, Binary}`. `ApiResponse` contains status, decoded bounded body and validated content type. No unchecked request interface is exposed to MCP.
|
||||
|
||||
- [ ] **Write `api_boundary` and `write_dispatch_boundary` parameterized tests:**
|
||||
- [x] **Write `api_boundary` and `write_dispatch_boundary` parameterized tests:**
|
||||
```rust
|
||||
assert_eq!(https_base("https://grocy.example/household").path(), "/household/api");
|
||||
assert_eq!(https_base("https://grocy.example/household/api/").path(), "/household/api");
|
||||
@@ -69,10 +69,10 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
assert_eq!(write_fixture.attempts(), 1);
|
||||
assert!(!upstream_error.exposed_text().contains("synthetic-private-marker"));
|
||||
```
|
||||
- [ ] **Run red:** `cargo test --test client_boundary`; expect missing implementation or failing boundary assertions.
|
||||
- [ ] **Implement these interfaces:** reject ambiguous URL encodings before normalization; encode substitutions as individual segments and recheck final origin/prefix. Disable reqwest retries/redirects/proxies/compression; stream bodies with exact caps. Mark writes potentially dispatched before polling send; classify transport/cancellation uncertainty conservatively. Retain no raw upstream body in errors. Custom CA parsing uses the same verified TLS policy. Capture one invalid-certificate fixture in this suite.
|
||||
- [ ] **Run green:** `cargo test --test client_boundary`; expect all cases passing.
|
||||
- [ ] **Commit:** `feat: enforce bounded API-only Grocy transport` with only this task's source, tests, license and build files.
|
||||
- [x] **Run red:** `cargo test --test client_boundary`; expect missing implementation or failing boundary assertions.
|
||||
- [x] **Implement these interfaces:** reject ambiguous URL encodings before normalization; encode substitutions as individual segments and recheck final origin/prefix. Disable reqwest retries/redirects/proxies/compression; stream bodies with exact caps. Mark writes potentially dispatched before polling send; classify transport/cancellation uncertainty conservatively. Retain no raw upstream body in errors. Custom CA parsing uses the same verified TLS policy. Capture one invalid-certificate fixture in this suite.
|
||||
- [x] **Run green:** `cargo test --test client_boundary`; expect all cases passing.
|
||||
- [x] **Commit:** `feat: enforce bounded API-only Grocy transport` with only this task's source, tests, license and build files.
|
||||
|
||||
### Task 2: Protect profiles with explicit desktop-wallet selection
|
||||
|
||||
@@ -80,7 +80,7 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
|
||||
**Interfaces:** `CredentialStore: Send + Sync` exposes `load(selection: ProfileSelection) -> BoxFuture<'static, Result<PrivateProfile>>`, `put(selection: ProfileSelection, profile: PrivateProfile, consent: EnrollmentConsent) -> BoxFuture<'static, Result<()>>`, and `forget(selection: ProfileSelection) -> BoxFuture<'static, Result<()>>`. `DesktopStore` implements it. `discover_providers() -> Result<Vec<ProviderCandidate>>`; `inspect_protection(identity: &ProviderIdentity) -> ProtectionEvidence`; `PreferenceStore::{load, save}` accepts only profile labels/provider identities. `EnrollmentConsent` distinguishes verified protection, acknowledged unverified protection and explicitly accepted unsafe backend storage.
|
||||
|
||||
- [ ] **Write `selected_wallet_only` and `protected_preferences` tests:**
|
||||
- [x] **Write `selected_wallet_only` and `protected_preferences` tests:**
|
||||
```rust
|
||||
assert_eq!(fake_bus.last_destination(), selected_unique_owner);
|
||||
assert_eq!(fake_bus.fallback_calls(), 0);
|
||||
@@ -94,10 +94,10 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
assert_eq!(preferences_mode(), 0o600);
|
||||
assert!(!preferences_text().contains("synthetic-key-or-url"));
|
||||
```
|
||||
- [ ] **Run red:** `cargo test --test credentials`; expect missing adapter or failed policy assertions.
|
||||
- [ ] **Implement:** use GIO to list names and resolve the selected live unique owner, retaining executable identity for subsequent operations. Open libsecret `Service::open_sync(Service::static_type(), Some(unique_owner), OPEN_SESSION, cancellable)` on a worker thread, and require `session_algorithms() == "dh-ietf1024-sha256-aes128-cbc-pkcs7"` before any Get/SetSecret. Use cancellable deadlines, existing default alias/collection, exact application+profile attributes and no auto-unlocking convenience lookup. Runtime loads only unlocked matching items; setup may explicitly request native unlock. Replacements use the service's single-item replace capability; interruption is reported honestly. Bound protection probes to owned regular files and known headers; KWallet format stays unverified. Preferences use no-follow reads and atomic mode-0600 writes in a mode-0700 app directory. Never expose library error details or silently activate/fallback. Synthetic provider exists only under test modules.
|
||||
- [ ] **Run green:** `cargo test --test credentials`; expect passing fake-bus and filesystem-policy cases without accessing real wallet secrets.
|
||||
- [ ] **Commit:** `feat: store profiles in the selected protected desktop wallet`.
|
||||
- [x] **Run red:** `cargo test --test credentials`; expect missing adapter or failed policy assertions.
|
||||
- [x] **Implement:** use GIO to list names and resolve the selected live unique owner, retaining executable identity for subsequent operations. Open libsecret `Service::open_sync(Service::static_type(), Some(unique_owner), OPEN_SESSION, cancellable)` on a worker thread, and require `session_algorithms() == "dh-ietf1024-sha256-aes128-cbc-pkcs7"` before any Get/SetSecret. Use cancellable deadlines, existing default alias/collection, exact application+profile attributes and no auto-unlocking convenience lookup. Runtime loads only unlocked matching items; setup may explicitly request native unlock. Replacements use the service's single-item replace capability; interruption is reported honestly. Bound protection probes to owned regular files and known headers; KWallet format stays unverified. Preferences use no-follow reads and atomic mode-0600 writes in a mode-0700 app directory. Never expose library error details or silently activate/fallback. Synthetic provider exists only under test modules.
|
||||
- [x] **Run green:** `cargo test --test credentials`; expect passing fake-bus and filesystem-policy cases without accessing real wallet secrets.
|
||||
- [x] **Commit:** `feat: store profiles in the selected protected desktop wallet`.
|
||||
|
||||
### Task 3: Define reviewed household records and quantity rules
|
||||
|
||||
@@ -105,7 +105,7 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
|
||||
**Interfaces:** `catalog::entities() -> &'static [EntitySpec]`; `catalog::operations(mode: AccessMode) -> Vec<OperationSpec>`; `catalog::validate(name: &str, args: Value, mode: AccessMode) -> Result<ValidatedCall>`. Specs carry schemas, route/method, read/write/destructive policy and verification strategy. `async records::run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>`; `resolve_record(rows: &[Value], selector: &RecordSelector) -> Result<RecordId>`; `convert_quantity(amount: Decimal, from: RecordId, stock_unit: RecordId, conversions: &[Conversion]) -> Result<Decimal>`. Define `RecordSelector::{Id, ExactName}` and positive decimal `Conversion` factors; never resolve names by substring.
|
||||
|
||||
- [ ] **Write `household_catalog_and_validation`:**
|
||||
- [x] **Write `household_catalog_and_validation`:**
|
||||
```rust
|
||||
assert!(read_only_operations().iter().all(|op| !op.mutates));
|
||||
assert!(catalog_excludes_accounts_keys_sessions_permissions_and_readonly_view_writes());
|
||||
@@ -117,10 +117,10 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
assert!(Page::new(0, 501).is_err());
|
||||
assert!(stock_unit_change_requires_valid_supported_conversion());
|
||||
```
|
||||
- [ ] **Run red:** `cargo test --test catalog`; expect catalog/validation failures.
|
||||
- [ ] **Implement:** reviewed schemas and finite entity CRUD for products, barcodes, locations, groups, units/conversions, shopping lists/items/locations, recipes/ingredients/nestings, meal plans/sections, chores, tasks/categories, batteries, equipment, userfields/entities/objects. Read-only views remain readable. Validate known editable fields against reviewed upstream definitions; reject server-owned/unknown fields. Userfield target entities must also be allowed. Add record description/name lookup tools, restricted assignment-user ID/name projection, deterministic pagination and explicit ambiguity candidates. Validate product references/units/location metadata and only Grocy-supported unit-change procedures. Do not fetch system configuration or permissions to guess capabilities.
|
||||
- [ ] **Run green:** `cargo test --test catalog`; expect all shared policy cases passing. Review one product CRUD schema before expanding the same catalog pattern across other entities.
|
||||
- [ ] **Commit:** `feat: add reviewed household records and unit validation`.
|
||||
- [x] **Run red:** `cargo test --test catalog`; expect catalog/validation failures.
|
||||
- [x] **Implement:** reviewed schemas and finite entity CRUD for products, barcodes, locations, groups, units/conversions, shopping lists/items/locations, recipes/ingredients/nestings, meal plans/sections, chores, tasks/categories, batteries, equipment, userfields/entities/objects. Read-only views remain readable. Validate known editable fields against reviewed upstream definitions; reject server-owned/unknown fields. Userfield target entities must also be allowed. Add record description/name lookup tools, restricted assignment-user ID/name projection, deterministic pagination and explicit ambiguity candidates. Validate product references/units/location metadata and only Grocy-supported unit-change procedures. Do not fetch system configuration or permissions to guess capabilities.
|
||||
- [x] **Run green:** `cargo test --test catalog`; expect all shared policy cases passing. Review one product CRUD schema before expanding the same catalog pattern across other entities.
|
||||
- [x] **Commit:** `feat: add reviewed household records and unit validation`.
|
||||
|
||||
### Task 4: Implement domain workflows, files and honest write results
|
||||
|
||||
@@ -128,7 +128,7 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
|
||||
**Interfaces:** Each domain exposes `async run(client: &ApiClient, call: &ValidatedCall) -> Result<ToolOutput>`. `tools::execute(client: &ApiClient, call: ValidatedCall) -> Result<ToolOutput>` dispatches finite catalog entries. `async verify_write(client: &ApiClient, receipt: ApiResponse, target: VerificationTarget) -> Result<ToolOutput>` preserves confirmed dispatch independently of readback. `VerificationTarget::{Readback(ApiRequest), Unavailable}` selects one reviewed bounded read or explicitly unavailable verification. `Attachment` is created only after decoded-size/MIME validation. No upload/download interface accepts a local path or external URL.
|
||||
|
||||
- [ ] **Write `workflow_results_and_files`:**
|
||||
- [x] **Write `workflow_results_and_files`:**
|
||||
```rust
|
||||
assert_eq!(volatile_result.data["due_products"][0]["id"], 7);
|
||||
assert!(successful_204.write.unwrap().status == WriteStatus::Completed);
|
||||
@@ -140,10 +140,10 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
assert!(filename_traversal_and_oversize_base64_are_rejected_before_request());
|
||||
assert!(api_ical_is_text_calendar_and_shopping_export_contains_actual_items());
|
||||
```
|
||||
- [ ] **Run red:** `cargo test --test workflows`; expect unimplemented actions/results.
|
||||
- [ ] **Implement:** stock reads/actions/entry edits, copy/merge, barcode variants, bookings/transactions and undo; shopping additions/removals/clear/missing/expired/overdue; recipe fulfillment/copy/consume/shopping additions and transaction undo; chores executions/undo/assignment recalculation/merge; battery charge/undo; task complete/undo and rescheduling through record updates. Inventory allows a new amount of zero; add/consume/transfer/open require positive amounts. Return one suitable bounded readback and distinguish incomplete verification. For grouped stock results, preserve groups and page each reported group explicitly. Files use household groups `equipmentmanuals`, `recipepictures`, `productpictures`, `userfiles`; exclude account avatar mutation. Use Grocy-required base64 filenames and binary PUT bodies; validate image signatures/types, otherwise embedded bounded resources. Export iCal through `/calendar/ical`; produce a shopping-list data export from API records, never call printer endpoints. Unsupported-version operations return explicit capability errors, with no fabricated fallback writes.
|
||||
- [ ] **Run green:** `cargo test --test workflows`; expect passing domain/result/binary cases. Coverage inventory accounts for every reviewed household upstream route and excluded operation.
|
||||
- [ ] **Commit:** `feat: implement household actions files and verified write receipts`.
|
||||
- [x] **Run red:** `cargo test --test workflows`; expect unimplemented actions/results.
|
||||
- [x] **Implement:** stock reads/actions/entry edits, copy/merge, barcode variants, bookings/transactions and undo; shopping additions/removals/clear/missing/expired/overdue; recipe fulfillment/copy/consume/shopping additions and transaction undo; chores executions/undo/assignment recalculation/merge; battery charge/undo; task complete/undo and rescheduling through record updates. Inventory allows a new amount of zero; add/consume/transfer/open require positive amounts. Return one suitable bounded readback and distinguish incomplete verification. For grouped stock results, preserve groups and page each reported group explicitly. Files use household groups `equipmentmanuals`, `recipepictures`, `productpictures`, `userfiles`; exclude account avatar mutation. Use Grocy-required base64 filenames and binary PUT bodies; validate image signatures/types, otherwise embedded bounded resources. Export iCal through `/calendar/ical`; produce a shopping-list data export from API records, never call printer endpoints. Unsupported-version operations return explicit capability errors, with no fabricated fallback writes.
|
||||
- [x] **Run green:** `cargo test --test workflows`; expect passing domain/result/binary cases. Coverage inventory accounts for every reviewed household upstream route and excluded operation.
|
||||
- [x] **Commit:** `feat: implement household actions files and verified write receipts`.
|
||||
|
||||
### Task 5: Expose useful and discoverable MCP tools
|
||||
|
||||
@@ -151,7 +151,7 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
|
||||
**Interfaces:** `McpServer::new(store: Arc<dyn CredentialStore>, selection: ProfileSelection, mode: AccessMode) -> McpServer`; `async mcp::serve_stdio(store: Arc<dyn CredentialStore>, selection: ProfileSelection) -> Result<()>`; `to_mcp_result(result: Result<ToolOutput>) -> rmcp::model::CallToolResult`. Server keeps only selection/mode/store and a process-local mutation mutex, loads a fresh profile per call and advertises the Task 3 catalog through `ServerHandler::list_tools/call_tool`.
|
||||
|
||||
- [ ] **Write `protocol_returns_data_and_policy`:**
|
||||
- [x] **Write `protocol_returns_data_and_policy`:**
|
||||
```rust
|
||||
assert_eq!(json_text_data(&inventory_result), inventory_result.structured_content.unwrap());
|
||||
assert_eq!(inventory_data["items"][0]["id"], 7);
|
||||
@@ -162,10 +162,10 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
assert!(parallel_mutations_are_serialized_and_reads_remain_bounded());
|
||||
assert!(descriptions_explain_units_dates_paging_and_uncertain_write_recovery());
|
||||
```
|
||||
- [ ] **Run red:** `cargo test --test mcp_protocol`; expect discovery/response failures using SDK duplex transport, not a production plaintext profile switch.
|
||||
- [ ] **Implement:** publish record CRUD/lookup/description tools and named finite domain operations with specific schemas, units and generic examples; truthful read-only/destructive/idempotency/open-world annotations. Register file/export tools with text plus image/resource content. Keep stdout protocol-only, sanitized diagnostics on stderr, bounded input/upload schemas and no request/body logging. Check the profile's current access mode again on every call; fail safely if re-enrollment changes advertised mode. Cancellation must not spawn a replay.
|
||||
- [ ] **Run green:** `cargo test --test mcp_protocol`; expect actual SDK discovery/calls and result equivalence passing.
|
||||
- [ ] **Commit:** `feat: expose data-rich household tools over stdio MCP`.
|
||||
- [x] **Run red:** `cargo test --test mcp_protocol`; expect discovery/response failures using SDK duplex transport, not a production plaintext profile switch.
|
||||
- [x] **Implement:** publish record CRUD/lookup/description tools and named finite domain operations with specific schemas, units and generic examples; truthful read-only/destructive/idempotency/open-world annotations. Register file/export tools with text plus image/resource content. Keep stdout protocol-only, sanitized diagnostics on stderr, bounded input/upload schemas and no request/body logging. Check the profile's current access mode again on every call; fail safely if re-enrollment changes advertised mode. Cancellation must not spawn a replay.
|
||||
- [x] **Run green:** `cargo test --test mcp_protocol`; expect actual SDK discovery/calls and result equivalence passing.
|
||||
- [x] **Commit:** `feat: expose data-rich household tools over stdio MCP`.
|
||||
|
||||
### Task 6: Make enrollment and Hermes connection understandable
|
||||
|
||||
@@ -173,7 +173,7 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
|
||||
**Interfaces:** `async setup::run(options: SetupOptions, ui: &mut dyn SetupUi, store: Arc<dyn CredentialStore>) -> Result<SetupOutcome>`; `async setup::doctor(selection: ProfileSelection, store: Arc<dyn CredentialStore>) -> Result<DoctorReport>`; `hermes::render_config(executable: &Path, selection: &ProfileSelection) -> Result<String>`; `async hermes::connect(executable: &Path, selection: &ProfileSelection) -> Result<ConnectOutcome>`. `SetupUi` provides visible input, hidden secret input, defaulted choices, confirmation and messages; tests use scripted answers. Outcomes expose status/nonsecret recovery instructions, not a profile or key.
|
||||
|
||||
- [ ] **Write `guided_setup_and_hermes_preservation`:**
|
||||
- [x] **Write `guided_setup_and_hermes_preservation`:**
|
||||
```rust
|
||||
assert_eq!(new_setup.required_private_inputs(), ["instance URL", "API key"]);
|
||||
assert!(kde_provider_is_recommended_without_assuming_kwallet_password_protection());
|
||||
@@ -185,10 +185,10 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
assert_eq!(unrelated_hermes_configuration_before, unrelated_hermes_configuration_after);
|
||||
assert!(rendered_block_contains_no_key_server_url_or_private_metadata());
|
||||
```
|
||||
- [ ] **Run red:** `cargo test --test setup`; expect wizard/configuration failures.
|
||||
- [ ] **Implement:** `setup`, `serve`, `doctor`, `wallets`, `print-config`, `connect-hermes`, `forget`. Choose the desktop provider/default wallet; show native steps for unavailable, locked, unverified or unprotected storage. Explain a dedicated Grocy user, household permissions, Manage API keys, upstream identity-provider 2FA and model/history privacy. Ask URL and masked key, discover version/time/household defaults via API only, reuse prior selections, and show one final review before storage/replacement. Timezone comes from server metadata or local IANA configuration; offer detected choices, with manual search only as recovery. Clarify TLS/custom-CA/Authelia/migration errors without root access. Inspect the installed Hermes CLI preserving-writer interface read-only, then use its conflict-safe writer and discovery check; if unavailable print a credential-free block and exact installation steps. Do not replace YAML wholesale. Forget only the matching wallet item/preferences and explain server-key revocation.
|
||||
- [ ] **Run green:** `cargo test --test setup`; expect scripted first-run/retry/re-enrollment/conflict flows passing. Walk through the rendered help once as an unassisted user.
|
||||
- [ ] **Commit:** `feat: guide protected enrollment and Hermes registration`.
|
||||
- [x] **Run red:** `cargo test --test setup`; expect wizard/configuration failures.
|
||||
- [x] **Implement:** `setup`, `serve`, `doctor`, `wallets`, `print-config`, `connect-hermes`, `forget`. Choose the desktop provider/default wallet; show native steps for unavailable, locked, unverified or unprotected storage. Explain a dedicated Grocy user, household permissions, Manage API keys, upstream identity-provider 2FA and model/history privacy. Ask URL and masked key, discover version/time/household defaults via API only, reuse prior selections, and show one final review before storage/replacement. Timezone comes from server metadata or local IANA configuration; offer detected choices, with manual search only as recovery. Clarify TLS/custom-CA/Authelia/migration errors without root access. Inspect the installed Hermes CLI preserving-writer interface read-only, then use its conflict-safe writer and discovery check; if unavailable print a credential-free block and exact installation steps. Do not replace YAML wholesale. Forget only the matching wallet item/preferences and explain server-key revocation.
|
||||
- [x] **Run green:** `cargo test --test setup`; expect scripted first-run/retry/re-enrollment/conflict flows passing. Walk through the rendered help once as an unassisted user.
|
||||
- [x] **Commit:** `feat: guide protected enrollment and Hermes registration`.
|
||||
|
||||
### Task 7: Verify the complete contract and prepare public delivery
|
||||
|
||||
@@ -196,7 +196,7 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
|
||||
**Interfaces:** `scripts/docker_fixture.py` provides `start`, `test`, `stop` using one owned manifest in a temporary directory; start records the LinuxServer image digest, creates only tagged disposable resources and obtains a synthetic key through independent fixture bootstrap. `cargo test --test docker_contract -- --ignored` reads the synthetic fixture manifest in test code only. No released binary accepts fixture credentials. `ApiOnlyProxy` exposes root `/api` and deployment-prefix `/household/api`, rejects everything else and keeps only nonsensitive fixture request counters.
|
||||
|
||||
- [ ] **Write `docker_household_contract`:**
|
||||
- [x] **Write `docker_household_contract`:**
|
||||
```rust
|
||||
assert!(sdk_discovery_and_calls_return_real_synthetic_inventory());
|
||||
assert!(setup_and_doctor_pass_through_api_only_proxy());
|
||||
@@ -207,11 +207,11 @@ Task 1 defines `Result<T> = std::result::Result<T, AppError>`, `AccessMode::{Rea
|
||||
assert!(deployment_prefix_is_preserved());
|
||||
assert!(readonly_profile_cannot_mutate());
|
||||
```
|
||||
- [ ] **Run red:** `python scripts/docker_fixture.py start` then `python scripts/docker_fixture.py test`; expect assertions to catch any route/schema/workflow gap. Bootstrap Grocy separately, including migrations if needed, before exposing the API-only proxy. Bind host ports to loopback and keep all state outside source.
|
||||
- [ ] **Complete contract integration:** one scenario creates its own fixtures (units/locations/products, shopping, recipes/meal plan, chore/task/battery, equipment/custom data), exercises meaningful CRUD/action/undo per domain, and cleans up only its resources. Correct actual API-version mismatches and update the coverage table; no smoke-test claim in place of domain coverage. Record image digest and version in public compatibility docs. Add practical install/build/enroll/Hermes instructions, protection limits, no cross-client revision guarantee and uncertain-write recovery. CI runs fmt/clippy/focused tests; Docker remains an explicit contract command.
|
||||
- [ ] **Run green and final checks once:** `cargo fmt --check`; `cargo clippy --all-targets -- -D warnings`; `cargo test`; `python scripts/docker_fixture.py test`; `cargo build --release --locked`. Run one RustSec dependency check plus a tracked-source/history privacy inspection for secrets/private names/hosts/addresses and accidental generated artifacts. Inspect CLI help and release discovery. Fix concrete findings and rerun only affected checks.
|
||||
- [ ] **Perform one final spec review:** confirm API-only routing, all-domain coverage, wallet owner/IPC policy, correct results/write recovery and unassisted setup. Record actual tested limitations rather than claiming unsupported capabilities. Run `python scripts/docker_fixture.py stop` even after failure; never prune unrelated resources.
|
||||
- [ ] **Commit:** `docs: document verified Grocy compatibility and secure setup`. Deliver the build location, meaningful verification results and any limitations. Publish only sanitized source/docs/tests using the contributor's identity after the verified result is concrete; real-instance enrollment requires the human to enter their hidden key locally.
|
||||
- [x] **Run red:** `python scripts/docker_fixture.py start` then `python scripts/docker_fixture.py test`; expect assertions to catch any route/schema/workflow gap. Bootstrap Grocy separately, including migrations if needed, before exposing the API-only proxy. Bind host ports to loopback and keep all state outside source.
|
||||
- [x] **Complete contract integration:** one scenario creates its own fixtures (units/locations/products, shopping, recipes/meal plan, chore/task/battery, equipment/custom data), exercises meaningful CRUD/action/undo per domain, and cleans up only its resources. Correct actual API-version mismatches and update the coverage table; no smoke-test claim in place of domain coverage. Record image digest and version in public compatibility docs. Add practical install/build/enroll/Hermes instructions, protection limits, no cross-client revision guarantee and uncertain-write recovery. CI runs fmt/clippy/focused tests; Docker remains an explicit contract command.
|
||||
- [x] **Run green and final checks once:** `cargo fmt --check`; `cargo clippy --all-targets -- -D warnings`; `cargo test`; `python scripts/docker_fixture.py test`; `cargo build --release --locked`. Run one RustSec dependency check plus a tracked-source/history privacy inspection for secrets/private names/hosts/addresses and accidental generated artifacts. Inspect CLI help and release discovery. Fix concrete findings and rerun only affected checks.
|
||||
- [x] **Perform one final spec review:** confirm API-only routing, all-domain coverage, wallet owner/IPC policy, correct results/write recovery and unassisted setup. Record actual tested limitations rather than claiming unsupported capabilities. Run `python scripts/docker_fixture.py stop` even after failure; never prune unrelated resources.
|
||||
- [x] **Commit:** `docs: document verified Grocy compatibility and secure setup`. Deliver the build location, meaningful verification results and any limitations. Publish only sanitized source/docs/tests using the contributor's identity after the verified result is concrete; real-instance enrollment requires the human to enter their hidden key locally.
|
||||
|
||||
## Planning Self-Review
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Hermes Grocy MCP: design
|
||||
|
||||
Date: 2026-10-01. Status: written design approved; implementation plan in preparation.
|
||||
Date: 2026-10-01. Status: approved design implemented; compatibility and limits are documented in the public guides.
|
||||
|
||||
## Agreed outcome
|
||||
|
||||
Build a lightweight Rust stdio MCP for a self-hosted Grocy instance. Hermes must receive useful data and support full household reads and writes: creation, updates, deletion, and domain actions. Credentials and private connection information belong in the selected desktop wallet. Setup must work for a person without an LLM. Every MCP request to Grocy, including enrollment and diagnostics, goes through its API. Test against a disposable local Docker instance, not an existing household or the public demo. Maintain a clean AGPL-3.0-only repository using the contributor's existing public Git identity, without assistant attribution or personal artifacts.
|
||||
|
||||
The conversational direction and this written design are approved. The implementation plan is the remaining architectural review checkpoint. No further preference questions are needed to define the first release.
|
||||
The design and implementation plan were approved. The first release follows the scope below; concrete compatibility evidence is recorded in docs/compatibility.md.
|
||||
|
||||
## Approach and boundaries
|
||||
|
||||
|
||||
Reference in New Issue
Block a user