@sebamomann/plants-mcp 2.8.2 → 2.13.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 +86 -0
- package/README.md +54 -13
- package/dist/index.js +15 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,92 @@
|
|
|
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.13.0 — 2026-09-30
|
|
7
|
+
|
|
8
|
+
**New tool: `merge_soils`.** Tool count 78 → 79 (28 read, 33 write, 18 admin). A **minor** bump —
|
|
9
|
+
one new write tool, nothing renamed or removed:
|
|
10
|
+
|
|
11
|
+
- `POST /api/v1/soils/merge` with `sourceId`/`targetId` (both required) moves every plant and
|
|
12
|
+
potting-event record from `sourceId` onto `targetId`, then deletes `sourceId` — for duplicate
|
|
13
|
+
soil mixes (idea 89; prod had 23 soils for 96 plants, including a literal duplicate pair
|
|
14
|
+
differing only by a space).
|
|
15
|
+
- Unlike deleting a soil directly, it never refuses for living plants still using the source —
|
|
16
|
+
moving them onto the target first is the point, so nothing is left dangling. `sourceId`'s own
|
|
17
|
+
mix recipe (its components/parts) is dropped; `targetId` keeps its own unchanged.
|
|
18
|
+
- Irreversible, like `admin_merge_plant_types` — there is no unmerge tool for soils. Shares its
|
|
19
|
+
ownership-checked, transactional core (`app/_lib/soils/mergeSoils.ts`) with the app's own
|
|
20
|
+
"Zusammenführen mit …" action on the soil detail page, so an MCP-triggered merge and a
|
|
21
|
+
UI-triggered one behave identically.
|
|
22
|
+
|
|
23
|
+
## 2.12.0 — 2026-09-30
|
|
24
|
+
|
|
25
|
+
**`record_fertilization` now also logs the watering a feeding is given with.** Tool count
|
|
26
|
+
unchanged (78). A **minor** bump — one new response field, nothing renamed or removed, but a
|
|
27
|
+
behaviour change worth reading before the next call:
|
|
28
|
+
|
|
29
|
+
- Fertilizer goes into the can, so the app treats a fed plant as a watered plant. Every fertilized
|
|
30
|
+
plant now also gets that day's watering-type event — routed exactly like `record_watering` (a
|
|
31
|
+
`WateringEvent` for a scheduled plant, a `RefillEvent` for reservoir, a `HydroEvent` top-up for
|
|
32
|
+
hydro) — unless one already exists for that day. The response gains `alsoWatered: number[]`
|
|
33
|
+
listing the plants that got one.
|
|
34
|
+
- A plant skipped because it was already fertilized that day is left entirely alone (no watering
|
|
35
|
+
either), as before.
|
|
36
|
+
- **Callers that used to pair `record_fertilization` with `record_watering` for the same plants
|
|
37
|
+
should drop the second call** — it would now be skipped as a same-day duplicate anyway, but the
|
|
38
|
+
extra round trip is wasted.
|
|
39
|
+
- This matches the app: its fertilize card's primary now reads "Düngen + gießen" and writes both,
|
|
40
|
+
and the old "also water" secondary is gone. Solid fertilizers (sticks, granules) are not modelled
|
|
41
|
+
yet — see `docs/feature-ideas.md` #79 — so the rule is unconditional for now.
|
|
42
|
+
|
|
43
|
+
## 2.11.0 — 2026-09-29
|
|
44
|
+
|
|
45
|
+
**`dismiss_care_recommendation` gains a new dismissible type, `possibleDecline`.** Tool count
|
|
46
|
+
unchanged (78). A **minor** bump — one new accepted enum value, nothing renamed or removed:
|
|
47
|
+
|
|
48
|
+
- Idea 72's deterministic "this plant might be drifting" signal, surfaced by
|
|
49
|
+
`list_care_recommendations` like any other recommendation: a plant never watered, a watering
|
|
50
|
+
rhythm stretching against its own past rhythm, repeated snoozes/"still moist" skips (idea 70),
|
|
51
|
+
a fertilization inside its post-repotting rest window, or watered markedly less often than other
|
|
52
|
+
plants of the same type in the collection. No AI runs to produce it — the owner's hard constraint
|
|
53
|
+
was that nothing may scan the collection to save on AI cost, and this reads only rows already
|
|
54
|
+
stored.
|
|
55
|
+
- `values.kind` names which of the above fired; `values.otherSignalCount` says how many more also
|
|
56
|
+
applied for that plant. Like `propagationCheckDue`, its dismissal is temporary (about a month),
|
|
57
|
+
not permanent — a still-true situation is meant to surface again, not hide forever.
|
|
58
|
+
- The in-app card's "genauer ansehen" opens the existing guided diagnosis pre-filled with the
|
|
59
|
+
signal; that flow itself has no MCP surface of its own yet (`list_diagnoses` is read-only), so
|
|
60
|
+
there is nothing further for this package to expose for it.
|
|
61
|
+
|
|
62
|
+
## 2.10.0 — 2026-09-29
|
|
63
|
+
|
|
64
|
+
**`set_plant_status` gains `deathNote`, and `deathCause` now also applies to `LOST`.** Tool count
|
|
65
|
+
unchanged (78). A **minor** bump — one new optional argument, one existing argument's scope
|
|
66
|
+
widened, nothing renamed or removed:
|
|
67
|
+
|
|
68
|
+
- `deathNote` (optional, max 300 characters) is a free-text detail alongside `deathCause`'s
|
|
69
|
+
constrained pick — idea 68's "picks, not free text as the primary, plus an optional sentence."
|
|
70
|
+
- `deathCause` previously applied only when `status` is `DEAD`; it (and the new `deathNote`) now
|
|
71
|
+
apply to `LOST` too. A plant marked `LOST` is gone the same way a `DEAD` one is — the app's
|
|
72
|
+
finding was that all 7 currently-lost plants (6 `DEAD`, 1 `LOST`) had a null `deathCause`, and
|
|
73
|
+
the `LOST` one had no path to set it at all. `deadDeclaredAt` stays `DEAD`-only: "declared dead
|
|
74
|
+
on" has no equivalent for a plant that was lost or discarded.
|
|
75
|
+
- Both fields are still entirely optional on every call — capturing a cause never gates the status
|
|
76
|
+
change itself (idea 68: "the cause must be skippable, since a gate would be worse than a null").
|
|
77
|
+
|
|
78
|
+
## 2.9.0 — 2026-09-29
|
|
79
|
+
|
|
80
|
+
**`record_repotting` gains a same-day duplicate guard.** Tool count unchanged (78). A **minor**
|
|
81
|
+
bump — one new optional argument, nothing renamed or removed:
|
|
82
|
+
|
|
83
|
+
- A plant that already has a potting event on the resolved `pottedAt` day now gets a `409` instead
|
|
84
|
+
of a second, identical event silently created (idea 59 — production data had turned up two
|
|
85
|
+
potting events for the same plant, 34 seconds apart). Pass `confirmDuplicate: true` to log a
|
|
86
|
+
genuine second repotting on the same day anyway.
|
|
87
|
+
- `record_watering`/`record_fertilization` and their app-side single-plant "mark now" actions are
|
|
88
|
+
unaffected by this bump — they already skip (rather than reject) a same-day repeat, and that
|
|
89
|
+
behavior is unchanged. The app's own single-plant quick-log buttons gained the same silent skip
|
|
90
|
+
in this release for consistency, but that is app-internal and not part of this package's surface.
|
|
91
|
+
|
|
6
92
|
## 2.8.2 — 2026-09-29
|
|
7
93
|
|
|
8
94
|
**Correction to 2.8.1.** Tool count unchanged (78). A **patch** bump — nothing renamed or removed:
|
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
|
-
**
|
|
63
|
+
**79 tools** over your collection: 28 that read it, 33 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,
|
|
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
|
|
115
|
+
- **`write`** — opt-in when you create the key. Gates the 33 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
|
|
@@ -201,12 +205,22 @@ overrides the Apr–Sep default), `locationId`.
|
|
|
201
205
|
| `get_care_calendar` | `GET /api/v1/care/calendar` | `days` (1–60, default 14) | Day-by-day projection plus an overdue group |
|
|
202
206
|
| `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 |
|
|
203
207
|
|
|
204
|
-
|
|
208
|
+
Five things to know when reading schedule results:
|
|
205
209
|
|
|
206
210
|
- **Days, not timestamps.** `nextDue` and `date` are local `YYYY-MM-DD` strings — a UTC timestamp
|
|
207
211
|
would render as the wrong day east of Greenwich.
|
|
208
212
|
- **`neverLogged` is its own bucket.** A plant with a cycle but no event yet has no anchor, so no
|
|
209
213
|
due date can be computed. Those are returned separately rather than reported as overdue.
|
|
214
|
+
- **`reservoirChecks` is where reservoir and hydro plants show up.** They have no watering cycle, so
|
|
215
|
+
they never appear in `tasks`, `days[]` or `neverLogged` at all. Instead, `list_due_care` and
|
|
216
|
+
`get_care_calendar` each carry a `reservoirChecks` array of `{ plantId, nextCheckAt,
|
|
217
|
+
daysUntilCheck, confidence }` — a *predicted* next check, the median gap between that plant's own
|
|
218
|
+
last few refills (reservoir) or hydro entries, not a schedule the user configured. `nextCheckAt` is
|
|
219
|
+
a `YYYY-MM-DD` day like `nextDue`; `daysUntilCheck` is negative once it has passed; `confidence` is
|
|
220
|
+
`"rough"` below three observed gaps and `"established"` from there on. The array ignores
|
|
221
|
+
`windowDays`/`days` — every plant with a prediction is always in it — and a plant with fewer than
|
|
222
|
+
two entries on distinct days has no entry at all. `list_overdue_care` carries the same array
|
|
223
|
+
narrowed to the checks whose day is today or already past (`daysUntilCheck <= 0`).
|
|
210
224
|
- **Fertilizing follows the app's calendar, including where it's grouped.** A fertilizing entry
|
|
211
225
|
carries `wateringAlignedOn` (a `YYYY-MM-DD` day, `null` when absent) — set when the app's
|
|
212
226
|
water-align rule has attached that feeding to a watering: unconditionally when it's due before
|
|
@@ -357,7 +371,7 @@ description starts with `WRITE:` so a model cannot mistake one for a read.
|
|
|
357
371
|
|---|---|---|
|
|
358
372
|
| `record_watering` | `POST /api/v1/care/watering` | `plantIds` **(required,** 1–200**)**, `wateredAt` (`YYYY-MM-DD`, defaults to now, no future dates) |
|
|
359
373
|
| `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` |
|
|
374
|
+
| `record_repotting` | `POST /api/v1/plants/:id/repotting` | `plantId`, `potId` **(both required)**, plus `potSizeId`, `soilId`, `pottedAt`, `hasDrainage`, `hasClimbingAid`, `notes`, `confirmDuplicate` |
|
|
361
375
|
| `record_refill` | `POST /api/v1/plants/:id/refill` | `plantId` **(required)**, plus `fertilizerId`, `fertilizerPercent`, `refilledAt` |
|
|
362
376
|
| `record_hydro_event` | `POST /api/v1/plants/:id/hydro` | `plantId` **(required)**, plus `kind` (`topup`\|`change`), `waterLevel` (0–100), `fertilizerId`, `fertilizerPercent`, `recordedAt`, `notes` |
|
|
363
377
|
| `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` |
|
|
@@ -369,11 +383,17 @@ fertilization logged.
|
|
|
369
383
|
`record_fertilization` defaults `fertilizerId` and `fertilizerPercent` to **each plant's own
|
|
370
384
|
settings** when omitted, so a bulk call across differently-configured plants still does the right
|
|
371
385
|
thing per plant. `record_refill` and `record_hydro_event` default the same two fields the same way.
|
|
386
|
+
Since 2.12.0 it also mirrors the app's rule that fertilizer goes into the can: every fertilized
|
|
387
|
+
plant gets that day's watering logged as well (routed like `record_watering` — a refill for
|
|
388
|
+
reservoir plants, a top-up for hydro) unless one already exists, and the response lists those
|
|
389
|
+
plants under `alsoWatered`. Do not follow it with `record_watering` for the same plants.
|
|
372
390
|
|
|
373
391
|
`record_repotting` re-derives the plant's current soil from its latest potting event, and syncs
|
|
374
392
|
watering/fertilizing mode when the new pot's self-watering flag changes it — the same the app's own
|
|
375
393
|
repotting form does. `pottedAt` defaults to today, or to the plant's `acquiredAt` for its very
|
|
376
|
-
first-ever potting event.
|
|
394
|
+
first-ever potting event. If the plant already has a potting event on that day, the call fails with
|
|
395
|
+
a 409 instead of creating a second one — pass `confirmDuplicate: true` to log a genuine second
|
|
396
|
+
repotting on the same day anyway.
|
|
377
397
|
|
|
378
398
|
`record_refill` is the dedicated tool for a reservoir plant's fertilizer/percent — `record_watering`
|
|
379
399
|
already routes a plain watering to a refill for these plants, but without those fields. Like the
|
|
@@ -445,7 +465,7 @@ don't exist yet, `propagate_plant` creates them *and* sets the link in one call.
|
|
|
445
465
|
| `propagate_plant` | `POST /api/v1/plants/:id/propagations` | `plantId` **(required)**, `count` (1–10, default 1) |
|
|
446
466
|
| `merge_plants` | `POST /api/v1/plants/merge` | `sourcePlantIds` **(required,** 1–200**)**, plus **exactly one of** `survivorPlantId` or `createNewFrom` |
|
|
447
467
|
| `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`/`
|
|
468
|
+
| `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
469
|
| `set_plant_reference` | `PATCH /api/v1/plants/:id/reference` | `plantId`, `reference` (boolean) **(both required)** |
|
|
450
470
|
| `establish_propagation` | `PATCH /api/v1/plants/:id/establish` | `plantId` **(required)** |
|
|
451
471
|
|
|
@@ -474,8 +494,10 @@ longer want it, deleting that plant is a UI action.
|
|
|
474
494
|
do**), and `MERGED`/`SPLIT`/`EXTERNAL` each have their own dedicated tool
|
|
475
495
|
(`merge_plants`/`unmerge_plant`, `set_plant_reference`) that keeps their companion state consistent —
|
|
476
496
|
setting `status` alone to one of those would corrupt it. `deadDeclaredAt` defaults to the plant's
|
|
477
|
-
existing value, then today
|
|
478
|
-
`
|
|
497
|
+
existing value, then today, and only applies to `DEAD`. `deathCause` (a constrained pick) and
|
|
498
|
+
`deathNote` (an optional free-text detail, max 300 characters) apply to `DEAD` and `LOST` alike —
|
|
499
|
+
a plant marked `LOST` is gone the same way a `DEAD` one is. Moving a plant to `LIVING` clears all
|
|
500
|
+
three.
|
|
479
501
|
|
|
480
502
|
`set_plant_reference` flips a plant between `LIVING` (yours) and `EXTERNAL` (a reference plant —
|
|
481
503
|
somebody else's, kept only as a lineage anchor): `reference: true` requires the plant to currently be
|
|
@@ -512,9 +534,9 @@ Pass all three **exactly as returned by `list_care_recommendations`**. The `fing
|
|
|
512
534
|
that specific occurrence, so dismissing one never suppresses a later, different recurrence.
|
|
513
535
|
|
|
514
536
|
Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `noFertilizerThisSeason`,
|
|
515
|
-
`recentlyRepottedAvoidFertilizer`, `noRecentPhoto`, `propagationCheckDue
|
|
516
|
-
**not** dismissible — it is a configuration contradiction, fixed by
|
|
517
|
-
hidden.
|
|
537
|
+
`recentlyRepottedAvoidFertilizer`, `noRecentPhoto`, `propagationCheckDue`, `possibleDecline`.
|
|
538
|
+
`wateringCycleMismatch` is **not** dismissible — it is a configuration contradiction, fixed by
|
|
539
|
+
editing the plant rather than hidden.
|
|
518
540
|
|
|
519
541
|
`propagationCheckDue` ("is it still a cutting?") fires for a plant that has been `PROPAGATING` for a
|
|
520
542
|
while and asks whether it has rooted. Dismissing it only hides it for a few weeks, not permanently —
|
|
@@ -522,6 +544,18 @@ it re-fires periodically rather than letting a stale `PROPAGATING` row silently
|
|
|
522
544
|
success rate in `collection_stats`. `establish_propagation` (see **Plant lifecycle** above) is how an
|
|
523
545
|
assistant acts on it — promoting the plant is the actual fix, not just dismissing the prompt.
|
|
524
546
|
|
|
547
|
+
`possibleDecline` is a deterministic, zero-cost "this plant might be drifting" signal — never
|
|
548
|
+
watered, a watering rhythm stretching against the plant's own past rhythm, repeated snoozes/"still
|
|
549
|
+
moist" skips, a fertilization inside its post-repotting rest window, or watered markedly less often
|
|
550
|
+
than other plants of the same type in the collection. No AI runs to produce it; `values.kind` names
|
|
551
|
+
which of those it is, and `values.otherSignalCount` says how many more also apply. Like
|
|
552
|
+
`propagationCheckDue`, dismissing it is temporary (about a month), since a still-true situation
|
|
553
|
+
should surface again rather than hide forever.
|
|
554
|
+
|
|
555
|
+
`noRecentPhoto` and `repotDue` dismiss the same way — temporarily, not forever — since their
|
|
556
|
+
fingerprints are anchored to the last photo/potting event and nothing short of a new one moves them:
|
|
557
|
+
`noRecentPhoto` re-fires after about a month, `repotDue` after about a season.
|
|
558
|
+
|
|
525
559
|
### Wishlist
|
|
526
560
|
|
|
527
561
|
| Tool | Endpoint | Arguments |
|
|
@@ -561,6 +595,7 @@ mirror (opening the page already marks everything read).
|
|
|
561
595
|
|---|---|---|
|
|
562
596
|
| `create_location` | `POST /api/v1/locations` | `name` **(required)**, `parentId` (an owned location, to nest under) |
|
|
563
597
|
| `create_soil` | `POST /api/v1/soils` | `name` **(required)**, `notes`, `items` (`{ componentName, parts }[]`, each component upserted by name) |
|
|
598
|
+
| `merge_soils` | `POST /api/v1/soils/merge` | `sourceId` **(required)**, `targetId` **(required)** |
|
|
564
599
|
| `create_fertilizer` | `POST /api/v1/fertilizers` | `name` **(required)**, `npk`, `baseDosePerLiterMl` (0–100) |
|
|
565
600
|
| `create_pot` | `POST /api/v1/pots` | `name` **(required)**, `material`, `isSelfWatering`, `notes`, `links` (`{ url, label? }[]`) |
|
|
566
601
|
| `create_plant_type` | `POST /api/v1/plant-types` | at least one of `genus`/`commonName`; `species`, `cultivar`, `values` (`sunRequirement`/`waterRequirement` required within it), `note`, `confirmDespiteDuplicates` |
|
|
@@ -570,6 +605,12 @@ inline" picker on the plant form — same validation, same duplicate-name handli
|
|
|
570
605
|
used by one of your own rows in that catalog is rejected). Pot *sizes* have no create tool yet; add
|
|
571
606
|
one from the app after creating the pot.
|
|
572
607
|
|
|
608
|
+
`merge_soils` moves every plant and potting-event record from `sourceId` onto `targetId`, then
|
|
609
|
+
deletes `sourceId` — for duplicate soil mixes (e.g. names differing only by a stray space). Unlike
|
|
610
|
+
deleting a soil directly, it never refuses for living plants still using it — moving them onto the
|
|
611
|
+
target is the point. `sourceId`'s own mix recipe is dropped; `targetId` keeps its own. There is no
|
|
612
|
+
unmerge — it is the one irreversible write tool in this catalog section.
|
|
613
|
+
|
|
573
614
|
`create_plant_type` adds a species/cultivar the shared catalog is missing — the same capability as
|
|
574
615
|
the plant-type picker's "Neue Art anlegen" dialog, always landing `UNVERIFIED`. Unlike that dialog
|
|
575
616
|
(which warns about likely duplicates client-side before you even submit), this checks
|
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
|
|
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));
|
|
@@ -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
|
|
@@ -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
|
|
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."),
|
|
@@ -392,6 +398,10 @@ server.tool("create_soil", "WRITE: create a soil mix, optionally with components
|
|
|
392
398
|
.optional()
|
|
393
399
|
.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."),
|
|
394
400
|
}, async ({ name, notes, items }) => apiSend("POST", "/api/v1/soils", { name, notes, items }));
|
|
401
|
+
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.", {
|
|
402
|
+
sourceId: z.number().int().positive().describe("The soil to merge away and delete. From list_soils."),
|
|
403
|
+
targetId: z.number().int().positive().describe("The soil that survives and absorbs sourceId's plants and potting events. From list_soils."),
|
|
404
|
+
}, async ({ sourceId, targetId }) => apiSend("POST", "/api/v1/soils/merge", { sourceId, targetId }));
|
|
395
405
|
server.tool("list_fertilizers", "List the user's fertilizers.", async () => apiGet("/api/v1/fertilizers"));
|
|
396
406
|
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.", {
|
|
397
407
|
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.
|
|
4
|
-
"description": "MCP server for the Sprig plant app:
|
|
3
|
+
"version": "2.13.0",
|
|
4
|
+
"description": "MCP server for the Sprig plant app: 79 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, 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>",
|