@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 +49 -0
- package/README.md +20 -8
- package/dist/index.js +10 -4
- package/package.json +1 -1
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`/`
|
|
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
|
|
478
|
-
`
|
|
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
|
|
516
|
-
**not** dismissible — it is a configuration contradiction, fixed by
|
|
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
|
|
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
|
|
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.
|
|
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",
|