@sebamomann/plants-mcp 2.8.2 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,55 @@
3
3
  Notable changes to `@sebamomann/plants-mcp`. Versioning is semver against the **tool surface** —
4
4
  see the table in `AGENTS.md` for what counts as major, minor, and patch.
5
5
 
6
+ ## 2.11.0 — 2026-09-29
7
+
8
+ **`dismiss_care_recommendation` gains a new dismissible type, `possibleDecline`.** Tool count
9
+ unchanged (78). A **minor** bump — one new accepted enum value, nothing renamed or removed:
10
+
11
+ - Idea 72's deterministic "this plant might be drifting" signal, surfaced by
12
+ `list_care_recommendations` like any other recommendation: a plant never watered, a watering
13
+ rhythm stretching against its own past rhythm, repeated snoozes/"still moist" skips (idea 70),
14
+ a fertilization inside its post-repotting rest window, or watered markedly less often than other
15
+ plants of the same type in the collection. No AI runs to produce it — the owner's hard constraint
16
+ was that nothing may scan the collection to save on AI cost, and this reads only rows already
17
+ stored.
18
+ - `values.kind` names which of the above fired; `values.otherSignalCount` says how many more also
19
+ applied for that plant. Like `propagationCheckDue`, its dismissal is temporary (about a month),
20
+ not permanent — a still-true situation is meant to surface again, not hide forever.
21
+ - The in-app card's "genauer ansehen" opens the existing guided diagnosis pre-filled with the
22
+ signal; that flow itself has no MCP surface of its own yet (`list_diagnoses` is read-only), so
23
+ there is nothing further for this package to expose for it.
24
+
25
+ ## 2.10.0 — 2026-09-29
26
+
27
+ **`set_plant_status` gains `deathNote`, and `deathCause` now also applies to `LOST`.** Tool count
28
+ unchanged (78). A **minor** bump — one new optional argument, one existing argument's scope
29
+ widened, nothing renamed or removed:
30
+
31
+ - `deathNote` (optional, max 300 characters) is a free-text detail alongside `deathCause`'s
32
+ constrained pick — idea 68's "picks, not free text as the primary, plus an optional sentence."
33
+ - `deathCause` previously applied only when `status` is `DEAD`; it (and the new `deathNote`) now
34
+ apply to `LOST` too. A plant marked `LOST` is gone the same way a `DEAD` one is — the app's
35
+ finding was that all 7 currently-lost plants (6 `DEAD`, 1 `LOST`) had a null `deathCause`, and
36
+ the `LOST` one had no path to set it at all. `deadDeclaredAt` stays `DEAD`-only: "declared dead
37
+ on" has no equivalent for a plant that was lost or discarded.
38
+ - Both fields are still entirely optional on every call — capturing a cause never gates the status
39
+ change itself (idea 68: "the cause must be skippable, since a gate would be worse than a null").
40
+
41
+ ## 2.9.0 — 2026-09-29
42
+
43
+ **`record_repotting` gains a same-day duplicate guard.** Tool count unchanged (78). A **minor**
44
+ bump — one new optional argument, nothing renamed or removed:
45
+
46
+ - A plant that already has a potting event on the resolved `pottedAt` day now gets a `409` instead
47
+ of a second, identical event silently created (idea 59 — production data had turned up two
48
+ potting events for the same plant, 34 seconds apart). Pass `confirmDuplicate: true` to log a
49
+ genuine second repotting on the same day anyway.
50
+ - `record_watering`/`record_fertilization` and their app-side single-plant "mark now" actions are
51
+ unaffected by this bump — they already skip (rather than reject) a same-day repeat, and that
52
+ behavior is unchanged. The app's own single-plant quick-log buttons gained the same silent skip
53
+ in this release for consistency, but that is app-internal and not part of this package's surface.
54
+
6
55
  ## 2.8.2 — 2026-09-29
7
56
 
8
57
  **Correction to 2.8.1.** Tool count unchanged (78). A **patch** bump — nothing renamed or removed:
package/README.md CHANGED
@@ -357,7 +357,7 @@ description starts with `WRITE:` so a model cannot mistake one for a read.
357
357
  |---|---|---|
358
358
  | `record_watering` | `POST /api/v1/care/watering` | `plantIds` **(required,** 1–200**)**, `wateredAt` (`YYYY-MM-DD`, defaults to now, no future dates) |
359
359
  | `record_fertilization` | `POST /api/v1/care/fertilization` | `plantIds` **(required,** 1–200**)**, `fertilizerId`, `fertilizerPercent` (0–1000), `fertilizedAt` |
360
- | `record_repotting` | `POST /api/v1/plants/:id/repotting` | `plantId`, `potId` **(both required)**, plus `potSizeId`, `soilId`, `pottedAt`, `hasDrainage`, `hasClimbingAid`, `notes` |
360
+ | `record_repotting` | `POST /api/v1/plants/:id/repotting` | `plantId`, `potId` **(both required)**, plus `potSizeId`, `soilId`, `pottedAt`, `hasDrainage`, `hasClimbingAid`, `notes`, `confirmDuplicate` |
361
361
  | `record_refill` | `POST /api/v1/plants/:id/refill` | `plantId` **(required)**, plus `fertilizerId`, `fertilizerPercent`, `refilledAt` |
362
362
  | `record_hydro_event` | `POST /api/v1/plants/:id/hydro` | `plantId` **(required)**, plus `kind` (`topup`\|`change`), `waterLevel` (0–100), `fertilizerId`, `fertilizerPercent`, `recordedAt`, `notes` |
363
363
  | `snooze_care` | `POST /api/v1/care/snooze` | `plantId`, `careType` (`water`\|`fert`), `originalDueAt` **(all required)**, exactly one of `durationDays` (1, 3, 7, or 14) or `snoozedUntil` (`YYYY-MM-DD`), `note` |
@@ -373,7 +373,9 @@ thing per plant. `record_refill` and `record_hydro_event` default the same two f
373
373
  `record_repotting` re-derives the plant's current soil from its latest potting event, and syncs
374
374
  watering/fertilizing mode when the new pot's self-watering flag changes it — the same the app's own
375
375
  repotting form does. `pottedAt` defaults to today, or to the plant's `acquiredAt` for its very
376
- first-ever potting event.
376
+ first-ever potting event. If the plant already has a potting event on that day, the call fails with
377
+ a 409 instead of creating a second one — pass `confirmDuplicate: true` to log a genuine second
378
+ repotting on the same day anyway.
377
379
 
378
380
  `record_refill` is the dedicated tool for a reservoir plant's fertilizer/percent — `record_watering`
379
381
  already routes a plain watering to a refill for these plants, but without those fields. Like the
@@ -445,7 +447,7 @@ don't exist yet, `propagate_plant` creates them *and* sets the link in one call.
445
447
  | `propagate_plant` | `POST /api/v1/plants/:id/propagations` | `plantId` **(required)**, `count` (1–10, default 1) |
446
448
  | `merge_plants` | `POST /api/v1/plants/merge` | `sourcePlantIds` **(required,** 1–200**)**, plus **exactly one of** `survivorPlantId` or `createNewFrom` |
447
449
  | `unmerge_plant` | `POST /api/v1/plants/:id/unmerge` | `plantId` **(required)** |
448
- | `set_plant_status` | `PATCH /api/v1/plants/:id/status` | `plantId`, `status` (`LIVING`\|`DEAD`\|`LOST`) **(both required)**, plus `deadDeclaredAt`/`deathCause` (only apply when `status` is `DEAD`) |
450
+ | `set_plant_status` | `PATCH /api/v1/plants/:id/status` | `plantId`, `status` (`LIVING`\|`DEAD`\|`LOST`) **(both required)**, plus `deadDeclaredAt` (only applies when `status` is `DEAD`) and `deathCause`/`deathNote` (only apply when `status` is `DEAD` or `LOST`) |
449
451
  | `set_plant_reference` | `PATCH /api/v1/plants/:id/reference` | `plantId`, `reference` (boolean) **(both required)** |
450
452
  | `establish_propagation` | `PATCH /api/v1/plants/:id/establish` | `plantId` **(required)** |
451
453
 
@@ -474,8 +476,10 @@ longer want it, deleting that plant is a UI action.
474
476
  do**), and `MERGED`/`SPLIT`/`EXTERNAL` each have their own dedicated tool
475
477
  (`merge_plants`/`unmerge_plant`, `set_plant_reference`) that keeps their companion state consistent —
476
478
  setting `status` alone to one of those would corrupt it. `deadDeclaredAt` defaults to the plant's
477
- existing value, then today; moving a plant away from `DEAD` clears both `deadDeclaredAt` and
478
- `deathCause`.
479
+ existing value, then today, and only applies to `DEAD`. `deathCause` (a constrained pick) and
480
+ `deathNote` (an optional free-text detail, max 300 characters) apply to `DEAD` and `LOST` alike —
481
+ a plant marked `LOST` is gone the same way a `DEAD` one is. Moving a plant to `LIVING` clears all
482
+ three.
479
483
 
480
484
  `set_plant_reference` flips a plant between `LIVING` (yours) and `EXTERNAL` (a reference plant —
481
485
  somebody else's, kept only as a lineage anchor): `reference: true` requires the plant to currently be
@@ -512,9 +516,9 @@ Pass all three **exactly as returned by `list_care_recommendations`**. The `fing
512
516
  that specific occurrence, so dismissing one never suppresses a later, different recurrence.
513
517
 
514
518
  Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `noFertilizerThisSeason`,
515
- `recentlyRepottedAvoidFertilizer`, `noRecentPhoto`, `propagationCheckDue`. `wateringCycleMismatch` is
516
- **not** dismissible — it is a configuration contradiction, fixed by editing the plant rather than
517
- hidden.
519
+ `recentlyRepottedAvoidFertilizer`, `noRecentPhoto`, `propagationCheckDue`, `possibleDecline`.
520
+ `wateringCycleMismatch` is **not** dismissible — it is a configuration contradiction, fixed by
521
+ editing the plant rather than hidden.
518
522
 
519
523
  `propagationCheckDue` ("is it still a cutting?") fires for a plant that has been `PROPAGATING` for a
520
524
  while and asks whether it has rooted. Dismissing it only hides it for a few weeks, not permanently —
@@ -522,6 +526,14 @@ it re-fires periodically rather than letting a stale `PROPAGATING` row silently
522
526
  success rate in `collection_stats`. `establish_propagation` (see **Plant lifecycle** above) is how an
523
527
  assistant acts on it — promoting the plant is the actual fix, not just dismissing the prompt.
524
528
 
529
+ `possibleDecline` is a deterministic, zero-cost "this plant might be drifting" signal — never
530
+ watered, a watering rhythm stretching against the plant's own past rhythm, repeated snoozes/"still
531
+ moist" skips, a fertilization inside its post-repotting rest window, or watered markedly less often
532
+ than other plants of the same type in the collection. No AI runs to produce it; `values.kind` names
533
+ which of those it is, and `values.otherSignalCount` says how many more also apply. Like
534
+ `propagationCheckDue`, dismissing it is temporary (about a month), since a still-true situation
535
+ should surface again rather than hide forever.
536
+
525
537
  ### Wishlist
526
538
 
527
539
  | Tool | Endpoint | Arguments |
package/dist/index.js CHANGED
@@ -99,7 +99,7 @@ server.tool("list_due_care", "What needs water or fertilizer now: overdue work p
99
99
  .describe("Include already-overdue work (default true)."),
100
100
  }, async (args) => apiGet("/api/v1/care/due", { ...args, includeOverdue: args.includeOverdue?.toString() }));
101
101
  server.tool("list_overdue_care", "Only work whose due date has already passed, most overdue first, with how many days each is late. Use for 'what have I fallen behind on?'. A feeding waiting on a future watering (`wateringAlignedOn` set — see `get_care_calendar`) is not listed here even when its own `nextDue` has passed: it's due on the watering's day, not neglected.", careArgs, async (args) => apiGet("/api/v1/care/overdue", args));
102
- server.tool("get_care_calendar", "Day-by-day care schedule over a date range, plus an overdue group. Each plant appears on its next due day only — this projects the current schedule rather than simulating future waterings. Fertilizing follows the app calendar's water-align rule: a feeding due before a plant's next watering is bucketed there unconditionally, and one due shortly after may ride along with it (within a third of the watering interval, capped at a quarter of the feeding's own cycle) — either way the fertilizing entry's `wateringAlignedOn` (YYYY-MM-DD) names that day and the bucket it's placed in follows it, while `nextDue` keeps the feeding's own, unchanged due date. A plant with no watering schedule (reservoir/hydro) has no watering to attach a feeding to, so its `wateringAlignedOn` is always null and the feeding stays on its own due date.", {
102
+ server.tool("get_care_calendar", "Day-by-day care schedule over a date range, plus an overdue group. Each plant's watering appears on its next due day only — this projects the current schedule rather than simulating future waterings. Fertilizing follows the app calendar's water-align rule: a feeding due before a plant's next watering is bucketed there unconditionally; one due later is bucketed with whichever of the plant's watering days (projected forward from its rhythm) is nearest — pulled onto the earlier one within a third of the watering interval (capped at a quarter of the feeding's own cycle), otherwise held for the later one (capped at half the feeding's own cycle) — so a fertilizer-only day never appears when a watering is close. Either way the fertilizing entry's `wateringAlignedOn` (YYYY-MM-DD) names that day and the bucket it's placed in follows it, while `nextDue` keeps the feeding's own, unchanged due date. A plant with no watering schedule (reservoir/hydro) has no watering to attach a feeding to, so its `wateringAlignedOn` is always null and the feeding stays on its own due date.", {
103
103
  ...careArgs,
104
104
  days: z.number().int().min(1).max(60).optional().describe("Number of days to project (default 14, max 60)."),
105
105
  }, async (args) => apiGet("/api/v1/care/calendar", args));
@@ -151,7 +151,7 @@ server.tool("record_fertilization", "WRITE: log a fertilization for one or more
151
151
  .describe("Strength as a percentage of the base dose; defaults to each plant's own."),
152
152
  fertilizedAt: optionalPlantDate(""),
153
153
  }, async (args) => apiSend("POST", "/api/v1/care/fertilization", { ...args }));
154
- server.tool("record_repotting", "WRITE: log a repotting — new pot, and optionally a new soil mix and/or pot size. Re-derives the plant's current soil from its latest potting event, and syncs watering/fertilizing mode when the new pot's self-watering flag changes it (the same way the app's own repotting form does). pottedAt defaults to today, or to the plant's acquiredAt date if this is its very first-ever potting event. Requires a write-scoped API key.", {
154
+ server.tool("record_repotting", "WRITE: log a repotting — new pot, and optionally a new soil mix and/or pot size. Re-derives the plant's current soil from its latest potting event, and syncs watering/fertilizing mode when the new pot's self-watering flag changes it (the same way the app's own repotting form does). pottedAt defaults to today, or to the plant's acquiredAt date if this is its very first-ever potting event. Same-day guard: if the plant already has a potting event on the resolved pottedAt day, this fails with a 409 instead of creating a second one — pass confirmDuplicate: true to log a genuine second repotting on the same day anyway. Requires a write-scoped API key.", {
155
155
  plantId: z.number().int().positive().describe("Plant id."),
156
156
  potId: z.number().int().positive().describe("Pot id, from list_pots (the caller's own pots, plus shared defaults)."),
157
157
  potSizeId: z.number().int().positive().optional().describe("One of the pot's own recorded sizes, from that pot's row in list_pots."),
@@ -160,6 +160,10 @@ server.tool("record_repotting", "WRITE: log a repotting — new pot, and optiona
160
160
  hasDrainage: z.boolean().optional().describe("Whether the pot has a drainage hole."),
161
161
  hasClimbingAid: z.boolean().optional().describe("Whether a climbing aid (moss pole, trellis) was added."),
162
162
  notes: z.string().max(2000).optional().describe("Free-text notes about this repotting."),
163
+ confirmDuplicate: z
164
+ .boolean()
165
+ .optional()
166
+ .describe("Pass true to log a second potting event on a day that already has one — otherwise that call fails with a 409."),
163
167
  }, async ({ plantId, ...body }) => apiSend("POST", `/api/v1/plants/${plantId}/repotting`, body));
164
168
  server.tool("record_refill", "WRITE: log a reservoir refill for a reservoir-mode plant — the dedicated tool for logging the fertilizer/percent that go with it (record_watering already routes a plain watering to a refill for these plants, but without those fields). fertilizerId/fertilizerPercent default to the plant's own settings when omitted. When the plant fertilizes with every watering, also logs a matching fertilization. Requires a write-scoped API key.", {
165
169
  plantId: z.number().int().positive().describe("Plant id."),
@@ -239,6 +243,7 @@ server.tool("dismiss_care_recommendation", "WRITE: hide one care recommendation,
239
243
  "recentlyRepottedAvoidFertilizer",
240
244
  "noRecentPhoto",
241
245
  "propagationCheckDue",
246
+ "possibleDecline",
242
247
  ])
243
248
  .describe("Recommendation type, from list_care_recommendations."),
244
249
  fingerprint: z.string().min(1).describe("Occurrence fingerprint, from list_care_recommendations."),
@@ -277,14 +282,15 @@ server.tool("merge_plants", "WRITE: merge several living plants into one — for
277
282
  server.tool("unmerge_plant", "WRITE: reverse a merge for one plant — takes a MERGED plant back to LIVING and clears its link to the survivor. Undoes one source at a time, so call it per plant to fully reverse a multi-plant merge. The survivor is left in place; delete it in the UI if it was created by the merge and is no longer wanted. Requires a write-scoped API key.", {
278
283
  plantId: z.number().int().positive().describe("The MERGED plant to restore."),
279
284
  }, async ({ plantId }) => apiSend("POST", `/api/v1/plants/${plantId}/unmerge`));
280
- server.tool("set_plant_status", "WRITE: set a plant's lifecycle status to LIVING, DEAD, or LOST. This is deliberately narrower than the full status range the app's edit form offers: GIFTED/SOLD/TRADED need a recipient and are out of scope for an API key (see docs), and MERGED/SPLIT/EXTERNAL each have their own dedicated tools (merge_plants/unmerge_plant, set_plant_reference) that keep their companion state consistent — setting status alone to one of those would corrupt it. deadDeclaredAt/deathCause only apply when status is DEAD (deadDeclaredAt defaults to the plant's existing value, then today); moving away from DEAD clears both. Requires a write-scoped API key.", {
285
+ server.tool("set_plant_status", "WRITE: set a plant's lifecycle status to LIVING, DEAD, or LOST. This is deliberately narrower than the full status range the app's edit form offers: GIFTED/SOLD/TRADED need a recipient and are out of scope for an API key (see docs), and MERGED/SPLIT/EXTERNAL each have their own dedicated tools (merge_plants/unmerge_plant, set_plant_reference) that keep their companion state consistent — setting status alone to one of those would corrupt it. deadDeclaredAt only applies when status is DEAD (defaults to the plant's existing value, then today). deathCause/deathNote apply to DEAD and LOST alike — a plant marked LOST is gone the same way a DEAD one is, and both feed the same after-the-fact retrospective the app builds from watering/repotting/photo history. Moving to LIVING clears deadDeclaredAt, deathCause, and deathNote. Requires a write-scoped API key.", {
281
286
  plantId: z.number().int().positive().describe("Plant id."),
282
287
  status: z.enum(["LIVING", "DEAD", "LOST"]).describe("New status."),
283
288
  deadDeclaredAt: optionalPlantDate("Only applies when status is DEAD."),
284
289
  deathCause: z
285
290
  .enum(["overwatering", "underwatering", "pests", "disease", "rootRot", "light", "cold", "transplantShock", "oldAge", "unknown", "other"])
286
291
  .optional()
287
- .describe("Only applies when status is DEAD."),
292
+ .describe("Only applies when status is DEAD or LOST. A constrained pick, not free text — pass 'unknown' rather than omitting it when the cause genuinely isn't known but you want that on record."),
293
+ deathNote: z.string().max(300).optional().describe("Only applies when status is DEAD or LOST. Optional free-text detail alongside deathCause, e.g. 'moved outside for summer, forgot to bring in before the first frost'."),
288
294
  }, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}/status`, body));
289
295
  server.tool("set_plant_reference", "WRITE: flip a plant between LIVING (the caller's own) and EXTERNAL (a reference plant — somebody else's, kept only as a lineage anchor). reference: true requires the plant to currently be LIVING; false requires it to currently be EXTERNAL. Reversible either direction; nothing on the plant is lost (photos, notes, care history, lineage all stay). Marking a plant as a reference also removes it from any vacation plan, since a reference plant is never cared for. create_plant's reference flag covers making a *new* reference plant; this is for converting one already in the collection. Requires a write-scoped API key.", {
290
296
  plantId: z.number().int().positive().describe("Plant id."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sebamomann/plants-mcp",
3
- "version": "2.8.2",
3
+ "version": "2.11.0",
4
4
  "description": "MCP server for the Sprig plant app: 78 tools to read a plant collection, browse the shared global plant type catalog, log care (watering, fertilizing, repotting, refills, hydro events), edit a plant's identity/care/lifecycle status, add, propagate or merge plants, snooze a due date, check for nudges worth mentioning, manage a wishlist and notifications, watch a plant type for changes, check vacation status, read past problem diagnoses, create a location/soil/fertilizer/pot, and contribute a new species to the catalog. Read-only by default; writes need a write-scoped API key. Two tools can delete a watering/fertilization event; nothing else deletes collection history. 18 more tools read, edit, merge and review the shared global plant type catalog and the instance's operational status (queues, catalog health, activity, growth, community, trade and content aggregates) for an admin-scoped key.",
5
5
  "type": "module",
6
6
  "license": "MIT",