@sebamomann/plants-mcp 2.11.0 → 3.0.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,107 @@
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
+ ## 3.0.0 — 2026-10-04
7
+
8
+ **`noFertilizerThisSeason` is renamed `fertilizingNotStarted`.** Tool count unchanged (81). A **major**
9
+ bump — an accepted argument value was removed:
10
+
11
+ - `list_care_recommendations` no longer returns `noFertilizerThisSeason` ("no feeding since the
12
+ season started", which fired on day one of every season). The new type `fertilizingNotStarted`
13
+ fires only for a plant with a fertilizer and an active fertilizing cycle that has **never** been
14
+ fertilized, once a full cycle has passed since feeding could have started (acquisition, the end of
15
+ a repotting rest, or the end of a winter pause). Cuttings are excluded.
16
+ - `dismiss_care_recommendation`'s `type` enum: `noFertilizerThisSeason` → `fertilizingNotStarted`.
17
+ Old dismissals of the removed type no longer apply to anything.
18
+ - Additive: `wateringCycleMismatch.values` gains `season` (`"summer"`/`"winter"`), the column the
19
+ suggestion is expressed in. The mismatch is now judged against the cycle in effect when each
20
+ interval started (season, gradual season ramp, controlled climate), so a season change alone no
21
+ longer makes a plant look off-cycle.
22
+ - `list_notifications`: a `CHANGELOG_PUBLISHED` row may now carry `messageKey:
23
+ "changelogPublishedSummary"` with `values: { summary }` (that day's summary) instead of
24
+ `changelogPublished` with `{ count }`.
25
+
26
+ ## 2.16.0 — 2026-10-04
27
+
28
+ **Autumn "Prüfen" checks: `list_due_care` gains `checks`, new `dismiss_transition_check`.** Tool count
29
+ 81 (was 80). A **minor** bump — one new tool and one additive response field:
30
+
31
+ - `list_due_care` returns an additive `checks` array — `{ plantId, name, lastWateredAt, checkSince,
32
+ regularDueAt }` — for plants whose watering interval grew with the autumn season change while the
33
+ plant may dry out at its summer pace. A question, not a task: not in `tasks`/`counts`. Existing
34
+ fields are unchanged.
35
+ - New write tool `dismiss_transition_check` (`plantId`): answers a check with "noch feucht", hiding
36
+ it until its regular due date without moving that date. The other answer ("dry") is
37
+ `record_watering`.
38
+
39
+ ## 2.15.0 — 2026-10-04
40
+
41
+ **`update_plant_care` gains `keepWinterCare`; `get_plant` reports the effective schedule.** Tool count
42
+ unchanged (80). A **minor** bump — one new optional argument and new response fields:
43
+
44
+ - `update_plant_care` accepts `keepWinterCare: boolean`. A plant in a controlled-climate location
45
+ follows its summer watering and fertilizing cycle all year; `keepWinterCare: true` brings the
46
+ winter cycle back for both. The stored winter values are never cleared by it.
47
+ - `get_plant` returns `keepWinterCare`, the location's `controlledClimate` (inside `location`) and a
48
+ new top-level `followsSummerYearRound` boolean.
49
+ - **Behaviour change in `get_plant`:** its six schedule fields now show what scheduling actually uses.
50
+ For a plant that follows its summer cycle all year, the winter fields
51
+ (`wateringFrequencyWinterDays`, `wateringFrequencyWinter`, `fertilizingCycleWinterWeeks`) are the
52
+ *effective* (summer) values, not the stored winter ones. Nothing changes for plants outside a
53
+ controlled-climate location.
54
+ - `update_plant_care`'s result includes `keepWinterCare`.
55
+
56
+ ## 2.14.0 — 2026-10-04
57
+
58
+ **New tool: `update_location`; `create_location` gains `controlledClimate`.** Tool count 79 → 80
59
+ (28 read, 34 write, 18 admin). A **minor** bump — one new write tool and one new optional argument:
60
+
61
+ - `PATCH /api/v1/locations/:id` takes any of `name`, `parentId` (`null` = top level) and
62
+ `controlledClimate`; only the fields present change. Shares its core with the location detail
63
+ page's save action, so cycle checks on `parentId` behave identically.
64
+ - `controlledClimate` marks a place with constant light and warmth (e.g. a plant cabinet): plants
65
+ placed directly there follow their summer watering and fertilizing cycle all year, unless the
66
+ plant opts back into winter care. `create_location` accepts it too.
67
+ - `list_locations` now returns `controlledClimate` per location (a new response field — no
68
+ change needed by callers).
69
+
70
+ ## 2.13.0 — 2026-09-30
71
+
72
+ **New tool: `merge_soils`.** Tool count 78 → 79 (28 read, 33 write, 18 admin). A **minor** bump —
73
+ one new write tool, nothing renamed or removed:
74
+
75
+ - `POST /api/v1/soils/merge` with `sourceId`/`targetId` (both required) moves every plant and
76
+ potting-event record from `sourceId` onto `targetId`, then deletes `sourceId` — for duplicate
77
+ soil mixes (idea 89; prod had 23 soils for 96 plants, including a literal duplicate pair
78
+ differing only by a space).
79
+ - Unlike deleting a soil directly, it never refuses for living plants still using the source —
80
+ moving them onto the target first is the point, so nothing is left dangling. `sourceId`'s own
81
+ mix recipe (its components/parts) is dropped; `targetId` keeps its own unchanged.
82
+ - Irreversible, like `admin_merge_plant_types` — there is no unmerge tool for soils. Shares its
83
+ ownership-checked, transactional core (`app/_lib/soils/mergeSoils.ts`) with the app's own
84
+ "Zusammenführen mit …" action on the soil detail page, so an MCP-triggered merge and a
85
+ UI-triggered one behave identically.
86
+
87
+ ## 2.12.0 — 2026-09-30
88
+
89
+ **`record_fertilization` now also logs the watering a feeding is given with.** Tool count
90
+ unchanged (78). A **minor** bump — one new response field, nothing renamed or removed, but a
91
+ behaviour change worth reading before the next call:
92
+
93
+ - Fertilizer goes into the can, so the app treats a fed plant as a watered plant. Every fertilized
94
+ plant now also gets that day's watering-type event — routed exactly like `record_watering` (a
95
+ `WateringEvent` for a scheduled plant, a `RefillEvent` for reservoir, a `HydroEvent` top-up for
96
+ hydro) — unless one already exists for that day. The response gains `alsoWatered: number[]`
97
+ listing the plants that got one.
98
+ - A plant skipped because it was already fertilized that day is left entirely alone (no watering
99
+ either), as before.
100
+ - **Callers that used to pair `record_fertilization` with `record_watering` for the same plants
101
+ should drop the second call** — it would now be skipped as a same-day duplicate anyway, but the
102
+ extra round trip is wasted.
103
+ - This matches the app: its fertilize card's primary now reads "Düngen + gießen" and writes both,
104
+ and the old "also water" secondary is gone. Solid fertilizers (sticks, granules) are not modelled
105
+ yet — see `docs/feature-ideas.md` #79 — so the rule is unconditional for now.
106
+
6
107
  ## 2.11.0 — 2026-09-29
7
108
 
8
109
  **`dismiss_care_recommendation` gains a new dismissible type, `possibleDecline`.** Tool count
package/README.md CHANGED
@@ -60,7 +60,7 @@ needs a running Sprig instance and an API key from it. Start at the
60
60
 
61
61
  ## What an assistant can do
62
62
 
63
- **78 tools** over your collection: 28 that read it, 32 that write to it. **18 admin tools** are also
63
+ **81 tools** over your collection: 28 that read it, 35 that write to it. **18 admin tools** are also
64
64
  available — but only to a key created by an admin — for finding and understanding duplicates in the
65
65
  shared global plant type catalog, restructuring its genus/species/cultivar tree, reading, directly
66
66
  editing, merging, and reviewing proposals and duplicate candidates in that catalog, and (7 read-only,
@@ -75,7 +75,8 @@ hydro events, snoozing a due date, health notes, dismissing care recommendations
75
75
  identity/care schedule/settings/lifecycle status, adding a plant (identified or not), propagating
76
76
  one, merging plants together and reversing that merge, deleting a watering or fertilization event,
77
77
  managing the wishlist (add, note, remove), watching or unwatching a plant type, marking notifications
78
- read, creating a location/soil/fertilizer/pot, and contributing a new species to the shared catalog.
78
+ read, creating a location/soil/fertilizer/pot, merging a duplicate soil into another, and
79
+ contributing a new species to the shared catalog.
79
80
 
80
81
  **Plant identity moved to a shared, community-maintained catalog in 2.0.0** — see **Breaking changes
81
82
  in 2.0.0** below before upgrading a client that stored a `plantTypeId`.
@@ -111,7 +112,7 @@ healthy server, not a hang — press Ctrl-C.
111
112
  The scope lives on the API key, is enforced by the app, and cannot be widened from this side.
112
113
 
113
114
  - **`read`** — on every key. Gates all 28 read tools.
114
- - **`write`** — opt-in when you create the key. Gates the 32 write tools.
115
+ - **`write`** — opt-in when you create the key. Gates the 35 write tools.
115
116
  - **`admin`** — only offered when the key's creator is themselves an admin, and only while they
116
117
  still are one (the app re-checks this on every admin-tool call, not just at key creation). Gates
117
118
  the 18 admin catalog tools — 7 read-only, 11 that write — see **Admin tools** below. `admin` alone
@@ -139,7 +140,10 @@ Not oversights — deliberate limits:
139
140
  alternative, so those two are destructive and **cannot be undone**. `remove_from_wishlist` and
140
141
  `unwatch_type` are two more deletes, but not in the same sense — a wish or a watch is a personal
141
142
  to-do item / subscription with no history to lose, trivially re-added with `add_to_wishlist` /
142
- `watch_type` if removed by mistake.
143
+ `watch_type` if removed by mistake. `merge_soils` also ends with a deleted catalog entry, but as
144
+ a side effect of consolidating a duplicate, not a standalone delete: everything the source soil
145
+ referenced moves to the target first, so no plant or potting-event history is lost — only the
146
+ now-redundant soil row and its own mix recipe.
143
147
  - **No sales or trades.** Those records name a second person who never consented to your API key.
144
148
  - **No share-link creation.** Minting a public URL for your collection is a decision for the UI.
145
149
  - **No cross-user access.** Ownership comes from the key; a tool has no way to even express
@@ -170,7 +174,10 @@ facts (light, water, feeding need, toxicity, climate, growth, substrate/propagat
170
174
  region), keyed by the same field names `get_plant_type` uses — and its six schedule fields
171
175
  (`wateringFrequencySummerDays`/`WinterDays`, `wateringFrequencySummer`/`Winter`,
172
176
  `fertilizingCycleSummerWeeks`/`WinterWeeks`) as `{ value, source: "plant"|"type"|"none", typeValue }`
173
- instead of a raw value — see **Breaking changes in 2.0.0** below.
177
+ instead of a raw value — see **Breaking changes in 2.0.0** below. They show what scheduling actually
178
+ uses: for a plant in a controlled-climate location without `keepWinterCare`, the winter fields equal the
179
+ summer values, and `followsSummerYearRound` is `true` (also returned: `keepWinterCare` and
180
+ `location.controlledClimate`).
174
181
 
175
182
  ### Per-plant history
176
183
 
@@ -196,17 +203,27 @@ overrides the Apr–Sep default), `locationId`.
196
203
 
197
204
  | Tool | Endpoint | Extra arguments | Answers |
198
205
  |---|---|---|---|
199
- | `list_due_care` | `GET /api/v1/care/due` | `windowDays` (0–60, default 0 = that day only), `includeOverdue` (default true) | "What should I do today?" |
206
+ | `list_due_care` | `GET /api/v1/care/due` | `windowDays` (0–60, default 0 = that day only), `includeOverdue` (default true) | "What should I do today?" Also carries the additive `checks` array (autumn "Prüfen" questions — see `dismiss_transition_check`). |
200
207
  | `list_overdue_care` | `GET /api/v1/care/overdue` | — | "What have I fallen behind on?" — most overdue first, with days late |
201
208
  | `get_care_calendar` | `GET /api/v1/care/calendar` | `days` (1–60, default 14) | Day-by-day projection plus an overdue group |
202
- | `list_care_recommendations` | `GET /api/v1/care/recommendations` | none | Detected problems: cycle mismatch, chronic lateness, missed seasonal fertilizing, fertilizer pause after repotting, repotting overdue, stale photos |
209
+ | `list_care_recommendations` | `GET /api/v1/care/recommendations` | none | Detected problems: cycle mismatch, chronic lateness, fertilizing never started, fertilizer pause after repotting, repotting overdue, stale photos |
203
210
 
204
- Four things to know when reading schedule results:
211
+ Five things to know when reading schedule results:
205
212
 
206
213
  - **Days, not timestamps.** `nextDue` and `date` are local `YYYY-MM-DD` strings — a UTC timestamp
207
214
  would render as the wrong day east of Greenwich.
208
215
  - **`neverLogged` is its own bucket.** A plant with a cycle but no event yet has no anchor, so no
209
216
  due date can be computed. Those are returned separately rather than reported as overdue.
217
+ - **`reservoirChecks` is where reservoir and hydro plants show up.** They have no watering cycle, so
218
+ they never appear in `tasks`, `days[]` or `neverLogged` at all. Instead, `list_due_care` and
219
+ `get_care_calendar` each carry a `reservoirChecks` array of `{ plantId, nextCheckAt,
220
+ daysUntilCheck, confidence }` — a *predicted* next check, the median gap between that plant's own
221
+ last few refills (reservoir) or hydro entries, not a schedule the user configured. `nextCheckAt` is
222
+ a `YYYY-MM-DD` day like `nextDue`; `daysUntilCheck` is negative once it has passed; `confidence` is
223
+ `"rough"` below three observed gaps and `"established"` from there on. The array ignores
224
+ `windowDays`/`days` — every plant with a prediction is always in it — and a plant with fewer than
225
+ two entries on distinct days has no entry at all. `list_overdue_care` carries the same array
226
+ narrowed to the checks whose day is today or already past (`daysUntilCheck <= 0`).
210
227
  - **Fertilizing follows the app's calendar, including where it's grouped.** A fertilizing entry
211
228
  carries `wateringAlignedOn` (a `YYYY-MM-DD` day, `null` when absent) — set when the app's
212
229
  water-align rule has attached that feeding to a watering: unconditionally when it's due before
@@ -348,7 +365,7 @@ tools** → **Catalogs** below) lets a write-scoped key add it.
348
365
 
349
366
  ## Write tools
350
367
 
351
- **All 31 ordinary write tools require a `write`-scoped key**; a read-only key gets HTTP 403. Every
368
+ **All 32 ordinary write tools require a `write`-scoped key**; a read-only key gets HTTP 403. Every
352
369
  description starts with `WRITE:` so a model cannot mistake one for a read.
353
370
 
354
371
  ### Care logging
@@ -369,6 +386,10 @@ fertilization logged.
369
386
  `record_fertilization` defaults `fertilizerId` and `fertilizerPercent` to **each plant's own
370
387
  settings** when omitted, so a bulk call across differently-configured plants still does the right
371
388
  thing per plant. `record_refill` and `record_hydro_event` default the same two fields the same way.
389
+ Since 2.12.0 it also mirrors the app's rule that fertilizer goes into the can: every fertilized
390
+ plant gets that day's watering logged as well (routed like `record_watering` — a refill for
391
+ reservoir plants, a top-up for hydro) unless one already exists, and the response lists those
392
+ plants under `alsoWatered`. Do not follow it with `record_watering` for the same plants.
372
393
 
373
394
  `record_repotting` re-derives the plant's current soil from its latest potting event, and syncs
374
395
  watering/fertilizing mode when the new pot's self-watering flag changes it — the same the app's own
@@ -401,7 +422,7 @@ it is — that backlog is not the watering's doing.
401
422
 
402
423
  | Tool | Endpoint | Arguments |
403
424
  |---|---|---|
404
- | `update_plant_care` | `PATCH /api/v1/plants/:id/care` | `plantId` **(required)**, plus any of `wateringMode` (`scheduled`\|`reservoir`\|`hydro`), `fertilizingMode` (`scheduled`\|`with_watering`), `wateringFrequencySummer`/`wateringFrequencyWinter` (labels), `wateringFrequencySummerDays`/`wateringFrequencyWinterDays` (1–365), `wateringNotes`, `fertilizingCycleSummerWeeks`/`fertilizingCycleWinterWeeks` (0–52), `fertilizerPercent` (0–1000), `fertilizingNotes` |
425
+ | `update_plant_care` | `PATCH /api/v1/plants/:id/care` | `plantId` **(required)**, plus any of `wateringMode` (`scheduled`\|`reservoir`\|`hydro`), `fertilizingMode` (`scheduled`\|`with_watering`), `wateringFrequencySummer`/`wateringFrequencyWinter` (labels), `wateringFrequencySummerDays`/`wateringFrequencyWinterDays` (1–365), `wateringNotes`, `fertilizingCycleSummerWeeks`/`fertilizingCycleWinterWeeks` (0–52), `fertilizerPercent` (0–1000), `fertilizingNotes`, `keepWinterCare` (boolean — winter cycle despite a controlled-climate location) |
405
426
  | `update_plant` | `PATCH /api/v1/plants/:id` | `plantId` **(required)**, plus any of `plantTypeId`, `nickname`, `locationId`, `soilId`, `fertilizerId`, `quantity` (0–9999), `notes`, `tags` (replaces the full list), `sitterInstructions`, `parentPlantId` |
406
427
 
407
428
  Both take a `plantId` and edit **only the fields you pass** — an omitted field keeps its current
@@ -511,11 +532,20 @@ The two edit tools take an **`entryId`, not a `plantId`** — get it from `list_
511
532
  | Tool | Endpoint | Arguments |
512
533
  |---|---|---|
513
534
  | `dismiss_care_recommendation` | `POST /api/v1/care/recommendations/dismiss` | `plantId`, `type`, `fingerprint` — **all required** |
535
+ | `dismiss_transition_check` | `POST /api/v1/care/transition-checks/dismiss` | `plantId` **(required)** — a plant listed in `checks` of `list_due_care` |
536
+
537
+ `dismiss_transition_check` answers an autumn "Prüfen" check with "noch feucht": as the season changes
538
+ to winter the watering interval grows, so a plant that dries out at its summer pace would go
539
+ unreminded for longer. `list_due_care` lists such plants in `checks`
540
+ (`{ plantId, name, lastWateredAt, checkSince, regularDueAt }`) from the day the summer-pace watering
541
+ would have been due until the regular due date. The dismissal hides the check until then and never
542
+ moves the due date; a new watering reopens the question. The other answer, "dry", is a plain
543
+ `record_watering`.
514
544
 
515
545
  Pass all three **exactly as returned by `list_care_recommendations`**. The `fingerprint` identifies
516
546
  that specific occurrence, so dismissing one never suppresses a later, different recurrence.
517
547
 
518
- Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `noFertilizerThisSeason`,
548
+ Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `fertilizingNotStarted`,
519
549
  `recentlyRepottedAvoidFertilizer`, `noRecentPhoto`, `propagationCheckDue`, `possibleDecline`.
520
550
  `wateringCycleMismatch` is **not** dismissible — it is a configuration contradiction, fixed by
521
551
  editing the plant rather than hidden.
@@ -534,6 +564,10 @@ which of those it is, and `values.otherSignalCount` says how many more also appl
534
564
  `propagationCheckDue`, dismissing it is temporary (about a month), since a still-true situation
535
565
  should surface again rather than hide forever.
536
566
 
567
+ `noRecentPhoto` and `repotDue` dismiss the same way — temporarily, not forever — since their
568
+ fingerprints are anchored to the last photo/potting event and nothing short of a new one moves them:
569
+ `noRecentPhoto` re-fires after about a month, `repotDue` after about a season.
570
+
537
571
  ### Wishlist
538
572
 
539
573
  | Tool | Endpoint | Arguments |
@@ -571,8 +605,10 @@ mirror (opening the page already marks everything read).
571
605
 
572
606
  | Tool | Endpoint | Arguments |
573
607
  |---|---|---|
574
- | `create_location` | `POST /api/v1/locations` | `name` **(required)**, `parentId` (an owned location, to nest under) |
608
+ | `create_location` | `POST /api/v1/locations` | `name` **(required)**, `parentId` (an owned location, to nest under), `controlledClimate` (plants there follow their summer watering/fertilizing cycle year-round unless they opt back into winter care) |
609
+ | `update_location` | `PATCH /api/v1/locations/:id` | `locationId` **(required)**, `name`, `parentId` (`null` = top level), `controlledClimate` — only the fields passed change |
575
610
  | `create_soil` | `POST /api/v1/soils` | `name` **(required)**, `notes`, `items` (`{ componentName, parts }[]`, each component upserted by name) |
611
+ | `merge_soils` | `POST /api/v1/soils/merge` | `sourceId` **(required)**, `targetId` **(required)** |
576
612
  | `create_fertilizer` | `POST /api/v1/fertilizers` | `name` **(required)**, `npk`, `baseDosePerLiterMl` (0–100) |
577
613
  | `create_pot` | `POST /api/v1/pots` | `name` **(required)**, `material`, `isSelfWatering`, `notes`, `links` (`{ url, label? }[]`) |
578
614
  | `create_plant_type` | `POST /api/v1/plant-types` | at least one of `genus`/`commonName`; `species`, `cultivar`, `values` (`sunRequirement`/`waterRequirement` required within it), `note`, `confirmDespiteDuplicates` |
@@ -582,6 +618,12 @@ inline" picker on the plant form — same validation, same duplicate-name handli
582
618
  used by one of your own rows in that catalog is rejected). Pot *sizes* have no create tool yet; add
583
619
  one from the app after creating the pot.
584
620
 
621
+ `merge_soils` moves every plant and potting-event record from `sourceId` onto `targetId`, then
622
+ deletes `sourceId` — for duplicate soil mixes (e.g. names differing only by a stray space). Unlike
623
+ deleting a soil directly, it never refuses for living plants still using it — moving them onto the
624
+ target is the point. `sourceId`'s own mix recipe is dropped; `targetId` keeps its own. There is no
625
+ unmerge — it is the one irreversible write tool in this catalog section.
626
+
585
627
  `create_plant_type` adds a species/cultivar the shared catalog is missing — the same capability as
586
628
  the plant-type picker's "Neue Art anlegen" dialog, always landing `UNVERIFIED`. Unlike that dialog
587
629
  (which warns about likely duplicates client-side before you even submit), this checks
package/dist/index.js CHANGED
@@ -53,7 +53,7 @@ server.tool("list_plants", "List the user's plants, with optional filters. Retur
53
53
  offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
54
54
  sort: z.enum(["displayName", "createdAt", "updatedAt", "acquiredAt"]).optional().describe("Sort key. 'displayName' sorts by the plant's resolved name (nickname, or its type's name); the rest sort newest first."),
55
55
  }, async (args) => apiGet("/api/v1/plants", args));
56
- server.tool("get_plant", "Get one plant with full detail: identity (type, nickname, display name, botanical name, unidentified state — see 'type'), typeFacts (the type's effective light/water/feeding need, toxicity, climate, growth, substrate/propagation, family/native region — using the catalog field keys from get_plant_type), catalogs (location, soil, fertilizer), care config, and recent event summaries. The six schedule fields (wateringFrequencySummerDays/WinterDays, wateringFrequencySummer/Winter, fertilizingCycleSummerWeeks/WinterWeeks) are each { value, source: 'plant'|'type'|'none', typeValue } — value is what scheduling/display use, source says where it came from, typeValue is what the type would say even when the plant overrides it.", { id: z.number().int().describe("Plant id.") }, async ({ id }) => apiGet(`/api/v1/plants/${id}`));
56
+ server.tool("get_plant", "Get one plant with full detail: identity (type, nickname, display name, botanical name, unidentified state — see 'type'), typeFacts (the type's effective light/water/feeding need, toxicity, climate, growth, substrate/propagation, family/native region — using the catalog field keys from get_plant_type), catalogs (location, soil, fertilizer), care config, and recent event summaries. The six schedule fields (wateringFrequencySummerDays/WinterDays, wateringFrequencySummer/Winter, fertilizingCycleSummerWeeks/WinterWeeks) are each { value, source: 'plant'|'type'|'none', typeValue } — value is what scheduling/display use, source says where it came from, typeValue is what the type would say even when the plant overrides it. For a plant in a controlled-climate location (location.controlledClimate) that has not set keepWinterCare, followsSummerYearRound is true and the three winter schedule fields show the SUMMER values — the plant follows its summer cycle all year (the raw stored winter values are kept and return when keepWinterCare is set or the plant moves elsewhere).", { id: z.number().int().describe("Plant id.") }, async ({ id }) => apiGet(`/api/v1/plants/${id}`));
57
57
  // --- Events connected to a plant -------------------------------------------
58
58
  const eventArgs = {
59
59
  id: z.number().int().describe("Plant id."),
@@ -84,7 +84,7 @@ const careArgs = {
84
84
  .describe("Override the season (Apr–Sep is summer). Defaults to the season of `date`."),
85
85
  locationId: z.number().int().optional().describe("Only consider plants in this location."),
86
86
  };
87
- server.tool("list_due_care", "What needs water or fertilizer now: overdue work plus anything due on the given day. Use `windowDays` to look ahead. This is the right tool for 'what should I do today?'. A fertilizing entry carries `wateringAlignedOn` (YYYY-MM-DD, null when absent) when the app's calendar has grouped that feeding with a watering; `windowDays`/`includeOverdue` are judged by that day, not the feeding's own `nextDue` — see `get_care_calendar`'s description for the full rule.", {
87
+ server.tool("list_due_care", "What needs water or fertilizer now: overdue work plus anything due on the given day. Use `windowDays` to look ahead. This is the right tool for 'what should I do today?'. A fertilizing entry carries `wateringAlignedOn` (YYYY-MM-DD, null when absent) when the app's calendar has grouped that feeding with a watering; `windowDays`/`includeOverdue` are judged by that day, not the feeding's own `nextDue` — see `get_care_calendar`'s description for the full rule. The additive `checks` array lists autumn 'Prüfen' questions — { plantId, name, lastWateredAt, checkSince, regularDueAt } — for plants that dry out at their summer pace while the season change has stretched their interval: the summer-pace date (`checkSince`) has passed but the regular due date (`regularDueAt`) has not. A check is a question ('is the soil dry?'), not a task — it is not in `tasks`/`counts`. Answer 'dry' with `record_watering`, 'still moist' with `dismiss_transition_check`.", {
88
88
  ...careArgs,
89
89
  windowDays: z
90
90
  .number()
@@ -139,7 +139,7 @@ server.tool("record_watering", "WRITE: log a watering for one or more plants. Re
139
139
  plantIds: z.array(z.number().int().positive()).min(1).max(200).describe("Plant ids to water."),
140
140
  wateredAt: optionalPlantDate(""),
141
141
  }, async ({ plantIds, wateredAt }) => apiSend("POST", "/api/v1/care/watering", { plantIds, wateredAt }));
142
- server.tool("record_fertilization", "WRITE: log a fertilization for one or more plants. fertilizerId and fertilizerPercent default to each plant's own settings when omitted. Plants already fertilized that day are skipped. Requires a write-scoped API key.", {
142
+ server.tool("record_fertilization", "WRITE: log a fertilization for one or more plants. Fertilizer goes into the watering can, so each fertilized plant also gets that day's watering logged (a refill for reservoir plants, a top-up for hydro) unless one already exists — reported as alsoWatered; do not call record_watering for the same plants afterwards. fertilizerId and fertilizerPercent default to each plant's own settings when omitted. Plants already fertilized that day are skipped entirely. Requires a write-scoped API key.", {
143
143
  plantIds: z.array(z.number().int().positive()).min(1).max(200).describe("Plant ids to fertilize."),
144
144
  fertilizerId: z.number().int().positive().optional().describe("Override fertilizer; defaults to each plant's own."),
145
145
  fertilizerPercent: z
@@ -188,7 +188,7 @@ server.tool("snooze_care", "WRITE: postpone a plant's next watering or fertilizi
188
188
  originalDueAt: z.string().describe("The due date being pushed out, e.g. from list_due_care's nextDue for this plant/careType."),
189
189
  note: z.string().max(160).optional().describe("Free-text reason, e.g. 'away for the week'."),
190
190
  }, async (args) => apiSend("POST", "/api/v1/care/snooze", { ...args }));
191
- server.tool("update_plant_care", "WRITE: edit a plant's care schedule — watering mode, days/labels, fertilizing mode, cycles and percent, and the watering/fertilizing notes. Omitted fields keep their current value. null on wateringFrequencySummerDays/WinterDays, wateringFrequencySummer/Winter, or fertilizingCycleSummerWeeks/WinterWeeks means 'follow the type' — the plant's effective schedule then falls back to its type's suggested value (see get_plant's typeFacts / the schedule fields' typeValue) instead of having no schedule at all; a value equal to the type's suggested value is stored the same way (it follows the type). null on wateringNotes/fertilizingNotes just clears the note. Watering fields are ignored while the plant's watering isn't on a schedule (reservoir/hydro), and the fertilizing-cycle fields are ignored while it fertilizes with every watering — there's no cycle to configure in either case. If wateringMode/fertilizingMode is part of this same call, that check runs against the NEW mode, not the plant's current one — e.g. switching to 'reservoir' while also passing a watering cycle ignores that cycle. sunRequirement/waterRequirement are no longer settable here — light and water need are type-only facts now (get_plant's typeFacts, or get_plant_type). Use this (not update_plant) to fix a 'wateringCycleMismatch' recommendation where the mode itself is wrong. Requires a write-scoped API key.", {
191
+ server.tool("update_plant_care", "WRITE: edit a plant's care schedule — watering mode, days/labels, fertilizing mode, cycles and percent, and the watering/fertilizing notes. Omitted fields keep their current value. null on wateringFrequencySummerDays/WinterDays, wateringFrequencySummer/Winter, or fertilizingCycleSummerWeeks/WinterWeeks means 'follow the type' — the plant's effective schedule then falls back to its type's suggested value (see get_plant's typeFacts / the schedule fields' typeValue) instead of having no schedule at all; a value equal to the type's suggested value is stored the same way (it follows the type). null on wateringNotes/fertilizingNotes just clears the note. Watering fields are ignored while the plant's watering isn't on a schedule (reservoir/hydro), and the fertilizing-cycle fields are ignored while it fertilizes with every watering — there's no cycle to configure in either case. If wateringMode/fertilizingMode is part of this same call, that check runs against the NEW mode, not the plant's current one — e.g. switching to 'reservoir' while also passing a watering cycle ignores that cycle. sunRequirement/waterRequirement are no longer settable here — light and water need are type-only facts now (get_plant's typeFacts, or get_plant_type). Use this (not update_plant) to fix a 'wateringCycleMismatch' recommendation where the mode itself is wrong. keepWinterCare brings the winter cycle back for a plant in a controlled-climate location (which otherwise follows its summer cycle all year, see get_plant's followsSummerYearRound) — it governs both watering and fertilizing and has no effect elsewhere; the stored winter values are never cleared by it. Requires a write-scoped API key.", {
192
192
  plantId: z.number().int().positive().describe("Plant id."),
193
193
  wateringMode: z.enum(["scheduled", "reservoir", "hydro"]).optional().describe("How this plant is watered: on a schedule, from a reservoir, or in water/hydro culture."),
194
194
  fertilizingMode: z.enum(["scheduled", "with_watering"]).optional().describe("How this plant is fertilized: on its own cycle, or automatically with every watering."),
@@ -201,6 +201,7 @@ server.tool("update_plant_care", "WRITE: edit a plant's care schedule — wateri
201
201
  fertilizingCycleWinterWeeks: z.number().int().min(0).max(52).nullable().optional().describe("Winter fertilizing interval in weeks. null follows the type's suggested weeks. Ignored while fertilizing is 'with every watering'."),
202
202
  fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose."),
203
203
  fertilizingNotes: z.string().max(500).nullable().optional().describe("Free-text fertilizing notes."),
204
+ keepWinterCare: z.boolean().optional().describe("true: use the winter cycle despite a controlled-climate location; false: follow the summer cycle all year there. Only matters while the plant stands in a location with controlledClimate."),
204
205
  }, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}/care`, body));
205
206
  server.tool("update_plant", "WRITE: edit a plant's type, nickname, location, soil, fertilizer, quantity, notes, tags, sitter instructions, or parent plant. Omitted fields keep their current value; null unassigns a catalog entry, clears notes/instructions, empties the tag list, or detaches the plant from its parent — and for plantTypeId, makes the plant 'unidentified'. nickname is required whenever the plant's state after this edit has no type (whether because plantTypeId is set to null here, or was already null and nickname isn't being set to something non-empty) — see plantIdentity's validatePlantNickname. Requires a write-scoped API key.", {
206
207
  plantId: z.number().int().positive().describe("Plant id."),
@@ -239,7 +240,7 @@ server.tool("dismiss_care_recommendation", "WRITE: hide one care recommendation,
239
240
  .enum([
240
241
  "wateringOftenLate",
241
242
  "fertilizingOftenLate",
242
- "noFertilizerThisSeason",
243
+ "fertilizingNotStarted",
243
244
  "recentlyRepottedAvoidFertilizer",
244
245
  "noRecentPhoto",
245
246
  "propagationCheckDue",
@@ -248,6 +249,9 @@ server.tool("dismiss_care_recommendation", "WRITE: hide one care recommendation,
248
249
  .describe("Recommendation type, from list_care_recommendations."),
249
250
  fingerprint: z.string().min(1).describe("Occurrence fingerprint, from list_care_recommendations."),
250
251
  }, async (args) => apiSend("POST", "/api/v1/care/recommendations/dismiss", { ...args }));
252
+ server.tool("dismiss_transition_check", "WRITE: answer an autumn 'Prüfen' check with 'noch feucht' (still moist) — hides that plant's check until its regular due date, without moving the due date. Pass the plantId of an entry in `checks` of list_due_care. The answer is tied to the plant's last watering, so a new watering reopens the question next autumn/interval. For the other answer — the soil is dry — just call `record_watering`: a normal watering makes the check disappear. Requires a write-scoped API key.", {
253
+ plantId: z.number().int().positive().describe("Plant id, from `checks` of list_due_care."),
254
+ }, async (args) => apiSend("POST", "/api/v1/care/transition-checks/dismiss", { ...args }));
251
255
  // --- Deleting events ---------------------------------------------------------
252
256
  /**
253
257
  * The only destructive tools in this server. Everything else — plants,
@@ -388,7 +392,14 @@ server.tool("create_plant_type", "WRITE: add a species/cultivar the shared globa
388
392
  server.tool("create_location", "WRITE: create a location — the same 'create inline' picker on the plant form. Never conflicts with an existing owned catalog entry: a duplicate name is rejected by the underlying unique constraint, surfaced as an error. Requires a write-scoped API key.", {
389
393
  name: z.string().min(1).max(100).describe("Location name."),
390
394
  parentId: z.number().int().positive().optional().describe("An owned location id to nest this one under (from list_locations). Omit for a top-level location."),
391
- }, async ({ name, parentId }) => apiSend("POST", "/api/v1/locations", { name, parentId }));
395
+ controlledClimate: z.boolean().optional().describe("True for a place with constant light and warmth all year (e.g. a plant cabinet with a grow light): plants placed directly here follow their summer watering and fertilizing cycle year-round, unless the plant opts back into winter care. Omit for false."),
396
+ }, async ({ name, parentId, controlledClimate }) => apiSend("POST", "/api/v1/locations", { name, parentId, controlledClimate }));
397
+ server.tool("update_location", "WRITE: rename, move or change the climate setting of an owned location. Only the fields you pass change. Requires a write-scoped API key.", {
398
+ locationId: z.number().int().positive().describe("The location id (from list_locations)."),
399
+ name: z.string().min(1).max(100).optional().describe("New name."),
400
+ parentId: z.number().int().positive().nullable().optional().describe("An owned location id to nest this one under; null moves it to the top level. Omit to keep the current parent. Cycles are rejected."),
401
+ controlledClimate: z.boolean().optional().describe("True: plants placed directly in this location (not its sublocations) follow their summer watering and fertilizing cycle year-round, unless the plant opts back into winter care. False: normal seasonal cycle again. Omit to keep the current setting."),
402
+ }, async ({ locationId, name, parentId, controlledClimate }) => apiSend("PATCH", `/api/v1/locations/${locationId}`, { name, parentId, controlledClimate }));
392
403
  server.tool("list_soils", "List the user's soils.", async () => apiGet("/api/v1/soils"));
393
404
  server.tool("create_soil", "WRITE: create a soil mix, optionally with components (name + parts) — mirrors the app's soil mix editor. Each component is matched or created by name under the caller's account. Requires a write-scoped API key.", {
394
405
  name: z.string().min(1).max(100).describe("Soil name."),
@@ -398,6 +409,10 @@ server.tool("create_soil", "WRITE: create a soil mix, optionally with components
398
409
  .optional()
399
410
  .describe("Mix components as whole-number parts, e.g. [{ componentName: 'Kokoserde', parts: 3 }, { componentName: 'Perlite', parts: 1 }]. Omit for a soil with no recorded mix."),
400
411
  }, async ({ name, notes, items }) => apiSend("POST", "/api/v1/soils", { name, notes, items }));
412
+ server.tool("merge_soils", "WRITE: merge one soil into another — for duplicate soil mixes (e.g. names differing only by a stray space). Every plant and potting-event record on sourceId moves to targetId, then sourceId is deleted. Unlike deleting a soil directly, this never refuses for living plants still using it — moving them is the point. sourceId's own mix recipe (components/parts) is dropped; targetId keeps its own. Irreversible — there is no unmerge. Requires a write-scoped API key.", {
413
+ sourceId: z.number().int().positive().describe("The soil to merge away and delete. From list_soils."),
414
+ targetId: z.number().int().positive().describe("The soil that survives and absorbs sourceId's plants and potting events. From list_soils."),
415
+ }, async ({ sourceId, targetId }) => apiSend("POST", "/api/v1/soils/merge", { sourceId, targetId }));
401
416
  server.tool("list_fertilizers", "List the user's fertilizers.", async () => apiGet("/api/v1/fertilizers"));
402
417
  server.tool("create_fertilizer", "WRITE: create a fertilizer, with an optional NPK label and base dose (ml per liter of water) used to default record_fertilization's fertilizerPercent. Requires a write-scoped API key.", {
403
418
  name: z.string().min(1).max(100).describe("Fertilizer name."),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sebamomann/plants-mcp",
3
- "version": "2.11.0",
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.",
3
+ "version": "3.0.0",
4
+ "description": "MCP server for the Sprig plant app: 81 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, answer an autumn watering check, check for nudges worth mentioning, manage a wishlist and notifications, watch a plant type for changes, check vacation status, read past problem diagnoses, create or update a location (incl. its controlled-climate setting), create a soil/fertilizer/pot, merge a duplicate soil into another, 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",
7
7
  "author": "sebamomann <github@sebamomann.de>",