@sebamomann/plants-mcp 1.9.0 → 2.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,65 @@
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.0.0 — 2026-09-15
7
+
8
+ **Plant identity moves to a shared, community-maintained global catalog.** 56 tools registered
9
+ overall (22 read, 17 write, 17 admin). This is a **major** bump — see `docs/mcp-server.md`'s
10
+ "Breaking changes in 2.0.0" section for the full list; summary:
11
+
12
+ - **`plantTypeId` is now a global catalog id everywhere it appears** (`list_plants`, `get_plant`,
13
+ `create_plant`, `update_plant`) — a legacy per-user type id cached before this release does not
14
+ refer to the same row. Re-resolve any cached id via `list_plant_types`/`search_plant_types`.
15
+ - **`list_plant_types` now serves the shared global catalog**, not a per-user list.
16
+ - **New tools:**
17
+ - `search_plant_types` — fuzzy search the global catalog by name (any locale) or botanical name.
18
+ Same underlying endpoint as `list_plant_types`; `query` is required here, optional there.
19
+ - `get_plant_type` — one type's fact sheet: identity, names by locale, parent/cultivar children,
20
+ effective catalog facts with each field's source (`"own"`/`"inherited"`), approved image URLs,
21
+ and the caller's own plant count. A `MERGED` id transparently resolves to its survivor.
22
+ - **`create_plant`'s `plantTypeId` is now optional** — omit it (or pass `null`) to add an
23
+ "unidentified" plant; `nickname` is then required, matching the app's plant form.
24
+ - **`update_plant` gained `plantTypeId` and `nickname`** — re-identify a plant, or make it
25
+ unidentified (`plantTypeId: null` + a `nickname`).
26
+ - **`get_plant`'s `plantType` field is replaced by `type`** —
27
+ `{ plantTypeId, nickname, displayName, botanicalName, typeStatus, isUnidentified }`. `list_plants`
28
+ rows changed the same way.
29
+ - **`get_plant` gained `typeFacts`** — the type's effective light/water/feeding need, toxicity,
30
+ climate, growth, substrate/propagation, and family/native region.
31
+ - **`get_plant`'s six schedule fields are no longer raw values** — each is now
32
+ `{ value, source: "plant"|"type"|"none", typeValue }`.
33
+ - **`update_plant_care` no longer accepts `sunRequirement`/`waterRequirement`** — both are type-only
34
+ facts now, read via `get_plant`'s `typeFacts` or `get_plant_type`.
35
+ - **`null` on `update_plant_care`'s watering/fertilizing-cycle fields now means "follow the type"**
36
+ instead of "no schedule" — the tool's zod schema now accepts `null` for those four fields, which
37
+ it previously rejected. A value equal to the type's value is stored as `null` too, so the plant
38
+ keeps following later changes to the type; `update_plant` switching `plantTypeId` moves values
39
+ that followed the old type onto the new one (or, to no type, writes them in).
40
+ - **`list_plants`'s `sort=commonName` is renamed `sort=displayName`** (resolved in memory — no
41
+ longer a database column) and its `search` now matches the plant's nickname and its type's names
42
+ in every locale, not a legacy `PlantType.commonName` column.
43
+ - **`GlobalPlantTypeStatus` gained a `REJECTED` value** — an admin can reject a user-created
44
+ `UNVERIFIED` type in the app (required note, plus a merge/unlink outcome for its plants); the
45
+ row persists, analogous to `MERGED`. `list_plant_types`/`search_plant_types` never return one;
46
+ `get_plant_type` on a rejected id behaves like `MERGED` (redirects when the outcome was a merge).
47
+ `admin_get_plant_type`'s `status` can now be `"REJECTED"`. No new tool triggers this yet — see
48
+ `docs/mcp-server.md`'s roadmap (`admin_reject_plant_type`).
49
+
50
+ ## 1.10.0 — 2026-09-15
51
+
52
+ **Wishlist tools.** Four tools total — 54 registered overall (20 read, 17 write, 17 admin):
53
+
54
+ - `list_wishlist` — the caller's wishlist: each wish's plant type (display/botanical name,
55
+ `typeStatus`) when set, or a free-text `name` fallback, plus dated `notes` and
56
+ `ownedLivingCount` (how many `LIVING` plants of that type the caller already has).
57
+ - `add_to_wishlist` (write) — add a wish by `plantTypeId` or free-text `name`. Never creates a
58
+ second wish for a type the caller already has — returns the existing one instead
59
+ (`duplicate: true`).
60
+ - `add_wishlist_note` (write) — add a dated note to a wish (price, where to get it, a shop link).
61
+ - `remove_from_wishlist` (write) — remove a wish. Not counted as a "destructive" tool in the sense
62
+ `delete_watering_event`/`delete_fertilization_event` are — a wish carries no collection history,
63
+ and is trivially re-added.
64
+
6
65
  ## 1.9.0 — 2026-09-15
7
66
 
8
67
  **Admin keys can now read, directly edit, merge, and review the shared global plant type catalog.**
package/README.md CHANGED
@@ -60,17 +60,21 @@ 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
- **50 tools** over your collection: 19 that read it, 14 that write to it. **17 admin tools** are also
63
+ **56 tools** over your collection: 22 that read it, 17 that write to it. **17 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, and (6 read-only, 11
66
66
  that write) reading, directly editing, merging, and reviewing proposals and duplicate candidates in
67
67
  that catalog. See **Admin tools** below.
68
68
 
69
- Reads cover plants, watering and fertilization history, the full care timeline, photo metadata,
70
- health entries, the derived care schedule, collection-wide activity, and the lookup catalogs.
71
- Writes cover recording waterings and fertilizings, health notes, dismissing care recommendations,
72
- editing a plant's care schedule or settings, adding a plant, propagating one, merging plants
73
- together and reversing that merge, and deleting a watering or fertilization event.
69
+ Reads cover plants, the shared global plant type catalog, watering and fertilization history, the
70
+ full care timeline, photo metadata, health entries, the derived care schedule, collection-wide
71
+ activity, the wishlist, and the lookup catalogs. Writes cover recording waterings and fertilizings,
72
+ health notes, dismissing care recommendations, editing a plant's identity/care schedule/settings,
73
+ adding a plant (identified or not), propagating one, merging plants together and reversing that
74
+ merge, deleting a watering or fertilization event, and managing the wishlist (add, note, remove).
75
+
76
+ **Plant identity moved to a shared, community-maintained catalog in 2.0.0** — see **Breaking changes
77
+ in 2.0.0** below before upgrading a client that stored a `plantTypeId`.
74
78
 
75
79
  **Deleting a plant, a photo, or a catalog entry is not possible from here** — that stays UI-only.
76
80
  `delete_watering_event` and `delete_fertilization_event` are the two exceptions (see **What this
@@ -102,8 +106,8 @@ healthy server, not a hang — press Ctrl-C.
102
106
 
103
107
  The scope lives on the API key, is enforced by the app, and cannot be widened from this side.
104
108
 
105
- - **`read`** — on every key. Gates all 19 read tools.
106
- - **`write`** — opt-in when you create the key. Gates the 14 write tools.
109
+ - **`read`** — on every key. Gates all 22 read tools.
110
+ - **`write`** — opt-in when you create the key. Gates the 17 write tools.
107
111
  - **`admin`** — only offered when the key's creator is themselves an admin, and only while they
108
112
  still are one (the app re-checks this on every admin-tool call, not just at key creation). Gates
109
113
  the 17 admin catalog tools — 6 read-only, 11 that write — see **Admin tools** below. `admin` alone
@@ -124,11 +128,13 @@ point of the scope split.
124
128
 
125
129
  Not oversights — deliberate limits:
126
130
 
127
- - **No deletes, except two.** Deleting a plant, a photo, or a catalog entry stays UI-only — the
128
- blast radius of a misunderstood delete there is a multi-year history, and retiring a plant is a
129
- *status* change instead. `delete_watering_event` and `delete_fertilization_event` are the
130
- exceptions: a mis-logged event has no such reversible alternative, so those two are destructive
131
- and **cannot be undone**.
131
+ - **No deletes of collection history, except two.** Deleting a plant, a photo, or a catalog entry
132
+ stays UI-only — the blast radius of a misunderstood delete there is a multi-year history, and
133
+ retiring a plant is a *status* change instead. `delete_watering_event` and
134
+ `delete_fertilization_event` are the exceptions: a mis-logged event has no such reversible
135
+ alternative, so those two are destructive and **cannot be undone**. `remove_from_wishlist` is a
136
+ third delete, but not in the same sense — a wish is a personal to-do item with no history to
137
+ lose, trivially re-added with `add_to_wishlist` if removed by mistake.
132
138
  - **No sales or trades.** Those records name a second person who never consented to your API key.
133
139
  - **No share-link creation.** Minting a public URL for your collection is a decision for the UI.
134
140
  - **No cross-user access.** Ownership comes from the key; a tool has no way to even express
@@ -143,14 +149,24 @@ Not oversights — deliberate limits:
143
149
  | Tool | Endpoint | Arguments |
144
150
  |---|---|---|
145
151
  | `whoami` | `GET /api/v1/me` | none — who the key belongs to |
146
- | `list_plants` | `GET /api/v1/plants` | all optional: `status`, `lifecycle`, `locationId`, `plantTypeId`, `soilId`, `fertilizerId`, `tag` (exact match), `search` (type name / notes), `limit` (1–200), `offset`, `sort` |
147
- | `get_plant` | `GET /api/v1/plants/:id` | `id` **(required)** — full detail: catalogs, care config, lineage, recent events |
152
+ | `list_plants` | `GET /api/v1/plants` | all optional: `status`, `lifecycle`, `locationId`, `plantTypeId` (a **global** catalog id), `soilId`, `fertilizerId`, `tag` (exact match), `search` (nickname / type names / notes), `limit` (1–200), `offset`, `sort` (`displayName`\|`createdAt`\|`updatedAt`\|`acquiredAt`) |
153
+ | `get_plant` | `GET /api/v1/plants/:id` | `id` **(required)** — full detail: identity (`type`), `typeFacts`, catalogs, care config, lineage, recent events |
148
154
 
149
155
  `status` is one of `LIVING`, `DEAD`, `GIFTED`, `LOST`, `SOLD`, `TRADED`, `MERGED`, `SPLIT`,
150
156
  `EXTERNAL`; `lifecycle` is `PROPAGATING` or `ESTABLISHED`. `EXTERNAL` is a **reference plant** —
151
157
  somebody else's plant, recorded only as an anchor for propagation lineage. It is never cared for,
152
158
  never appears in due/overdue care, and is excluded from `collection_stats`.
153
159
 
160
+ Each plant's identity is a `type` object (replacing the old `plantType`):
161
+ `{ plantTypeId, nickname, displayName, botanicalName, typeStatus, isUnidentified }`.
162
+ `plantTypeId` is `null` for an "unidentified" plant — one with no catalog type, just a `nickname`.
163
+ `get_plant` additionally returns `typeFacts` — the type's effective (inheritance-resolved) catalog
164
+ facts (light, water, feeding need, toxicity, climate, growth, substrate/propagation, family/native
165
+ region), keyed by the same field names `get_plant_type` uses — and its six schedule fields
166
+ (`wateringFrequencySummerDays`/`WinterDays`, `wateringFrequencySummer`/`Winter`,
167
+ `fertilizingCycleSummerWeeks`/`WinterWeeks`) as `{ value, source: "plant"|"type"|"none", typeValue }`
168
+ instead of a raw value — see **Breaking changes in 2.0.0** below.
169
+
154
170
  ### Per-plant history
155
171
 
156
172
  All five take the same arguments: `id` **(required)**, `limit` (1–500), `offset`. Newest first.
@@ -220,23 +236,53 @@ the trip. Returns `{ hasVacation: false }` when there's no upcoming or running s
220
236
  this tool never creates or modifies one. `locationId` narrows the plant list to one location; the
221
237
  schedule always covers the whole vacation.
222
238
 
239
+ ### Wishlist
240
+
241
+ | Tool | Endpoint | Arguments |
242
+ |---|---|---|
243
+ | `list_wishlist` | `GET /api/v1/wishlist` | none |
244
+
245
+ Plants the caller wants but doesn't own yet. Each entry carries `plantTypeId` (when it's a global
246
+ catalog type, with its display/botanical name and `typeStatus`) or a free-text `name` fallback for
247
+ a species not yet in the catalog, a `notes` array (dated, e.g. price or where to get it), and
248
+ `ownedLivingCount` — how many `LIVING` plants of that type the caller already has.
249
+
223
250
  ### Catalogs
224
251
 
225
- For resolving the ids the filters take. No arguments.
252
+ For resolving the ids the filters take.
226
253
 
227
- | Tool | Endpoint |
228
- |---|---|
229
- | `list_locations` | `GET /api/v1/locations` |
230
- | `list_plant_types` | `GET /api/v1/plant-types` |
231
- | `list_soils` | `GET /api/v1/soils` |
232
- | `list_fertilizers` | `GET /api/v1/fertilizers` |
254
+ | Tool | Endpoint | Arguments |
255
+ |---|---|---|
256
+ | `list_locations` | `GET /api/v1/locations` | none |
257
+ | `list_plant_types` | `GET /api/v1/plant-types` | all optional: `query` (fuzzy search), `status` (`UNVERIFIED`\|`VERIFIED`), `inMyCollection`, `limit` (1–200), `offset` |
258
+ | `search_plant_types` | `GET /api/v1/plant-types` | `query` **(required)**, plus the same optional filters/paging as `list_plant_types` |
259
+ | `get_plant_type` | `GET /api/v1/plant-types/:id` | `id` **(required)** — one type's fact sheet |
260
+ | `list_soils` | `GET /api/v1/soils` | none |
261
+ | `list_fertilizers` | `GET /api/v1/fertilizers` | none |
262
+
263
+ **`list_plant_types` is the shared, community-maintained global catalog** — the same for every
264
+ user, not a per-user list. `list_plant_types` and `search_plant_types` are the same endpoint and
265
+ reader; they exist as two tools because browsing (no `query`) and searching (`query` required) are
266
+ different intents for a model to reach for, even though the underlying call is identical.
267
+ `inMyCollection: true` narrows to types the caller has at least one `LIVING` plant of — each row's
268
+ `viewerPlantsCount` says how many. A `MERGED` or `REJECTED` type never appears in either list.
269
+
270
+ `get_plant_type` returns `{ id, status, mergedIntoId, displayName, botanicalName, names, parent,
271
+ children, facts: { values, sources }, images, viewerPlantsCount, changeLog }`. `facts.values` are
272
+ the effective (inheritance-resolved) catalog values — the same field keys `get_plant`'s `typeFacts`
273
+ uses; `facts.sources` says `"own"` or `"inherited"` per field. `images` are approved URLs only. A
274
+ `MERGED` id **transparently resolves to its survivor** here (unlike the admin
275
+ `admin_get_plant_type`, which returns the redirect stub) — there is nothing for a non-admin caller
276
+ to do with the stub itself. A `REJECTED` id behaves the same way when its outcome was a merge
277
+ (`mergedIntoId` set); otherwise it resolves to the type's own now-`REJECTED` row, since there's no
278
+ survivor to point to.
233
279
 
234
280
  ---
235
281
 
236
282
  ## Write tools
237
283
 
238
- **All ten require a `write`-scoped key**; a read-only key gets HTTP 403. Every description starts
239
- with `WRITE:` so a model cannot mistake one for a read.
284
+ **All 17 ordinary write tools require a `write`-scoped key**; a read-only key gets HTTP 403. Every
285
+ description starts with `WRITE:` so a model cannot mistake one for a read.
240
286
 
241
287
  ### Care logging
242
288
 
@@ -257,21 +303,35 @@ thing per plant.
257
303
 
258
304
  | Tool | Endpoint | Arguments |
259
305
  |---|---|---|
260
- | `update_plant_care` | `PATCH /api/v1/plants/:id/care` | `plantId` **(required)**, plus any of `wateringMode` (`scheduled`\|`reservoir`\|`hydro`), `fertilizingMode` (`scheduled`\|`with_watering`), `sunRequirement` (1–4), `waterRequirement` (1–3), `wateringFrequencySummer`/`wateringFrequencyWinter` (labels), `wateringFrequencySummerDays`/`wateringFrequencyWinterDays` (1–365), `wateringNotes`, `fertilizingCycleSummerWeeks`/`fertilizingCycleWinterWeeks` (0–52), `fertilizerPercent` (0–1000), `fertilizingNotes` |
261
- | `update_plant` | `PATCH /api/v1/plants/:id` | `plantId` **(required)**, plus any of `locationId`, `soilId`, `fertilizerId`, `quantity` (0–9999), `notes`, `tags` (replaces the full list), `sitterInstructions`, `parentPlantId` |
306
+ | `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` |
307
+ | `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` |
262
308
 
263
309
  Both take a `plantId` and edit **only the fields you pass** — an omitted field keeps its current
264
310
  value, `null` clears it (unassigns a catalog id, or blanks a text field).
265
311
 
266
- `update_plant_care`'s watering fields (other than `sunRequirement`) only take effect while the
267
- plant's watering is on a schedule a reservoir/hydro plant has no cycle for them to configure, so
268
- they're silently ignored for one. The fertilizing-cycle fields are likewise ignored while the plant
269
- fertilizes with every watering rather than on its own cycle. Passing `wateringMode`/`fertilizingMode`
270
- in the **same call** as the fields they gate checks against the *new* mode, not the current one — so
271
- switching a plant to `"reservoir"` while also setting a watering cycle just drops that cycle rather
272
- than saving it. `careMode` (a legacy mirror of `wateringMode`) updates itself; it isn't a field you set.
273
- This is also the tool that actually fixes a `wateringCycleMismatch` recommendation — that
274
- recommendation's own text says "edit the plant," and the mode causing the mismatch lives here.
312
+ **`update_plant_care` no longer accepts `sunRequirement`/`waterRequirement`** light and water need
313
+ are type-only facts now (`get_plant`'s `typeFacts`, or `get_plant_type`). `null` on
314
+ `wateringFrequencySummerDays`/`WinterDays`, `wateringFrequencySummer`/`Winter`, or
315
+ `fertilizingCycleSummerWeeks`/`WinterWeeks` now means **"follow the type"** the plant's effective
316
+ schedule falls back to its type's suggested value (see `get_plant`'s `typeValue` on each schedule
317
+ field) rather than having no schedule at all; `null` on `wateringNotes`/`fertilizingNotes` still just
318
+ clears the note.
319
+
320
+ The watering fields only take effect while the plant's watering is on a schedule a reservoir/hydro
321
+ plant has no cycle for them to configure, so they're silently ignored for one. The fertilizing-cycle
322
+ fields are likewise ignored while the plant fertilizes with every watering rather than on its own
323
+ cycle. Passing `wateringMode`/`fertilizingMode` in the **same call** as the fields they gate checks
324
+ against the *new* mode, not the current one — so switching a plant to `"reservoir"` while also
325
+ setting a watering cycle just drops that cycle rather than saving it. `careMode` (a legacy mirror of
326
+ `wateringMode`) updates itself; it isn't a field you set. This is also the tool that actually fixes a
327
+ `wateringCycleMismatch` recommendation — that recommendation's own text says "edit the plant," and
328
+ the mode causing the mismatch lives here.
329
+
330
+ `update_plant`'s `plantTypeId` points the plant at a **global** catalog type (from
331
+ `list_plant_types`/`search_plant_types`; a `MERGED` id transparently resolves to its survivor); `null`
332
+ makes the plant "unidentified". `nickname` is required whenever the plant's state *after* this edit
333
+ has no type — set together with `plantTypeId: null`, or already unidentified and not gaining a type
334
+ in this same call.
275
335
 
276
336
  `update_plant`'s `locationId`/`soilId`/`fertilizerId` must be one of **your own** catalog ids (from
277
337
  `list_locations`/`list_soils`/`list_fertilizers`) — an unowned or unknown id is rejected.
@@ -285,13 +345,16 @@ don't exist yet, `propagate_plant` creates them *and* sets the link in one call.
285
345
 
286
346
  | Tool | Endpoint | Arguments |
287
347
  |---|---|---|
288
- | `create_plant` | `POST /api/v1/plants` | `plantTypeId` **(required)**, `reference` (default `false`), `parentPlantId` |
348
+ | `create_plant` | `POST /api/v1/plants` | `plantTypeId` (optional — omit/`null` for unidentified), `nickname` (required when unidentified), `reference` (default `false`), `parentPlantId` |
289
349
  | `propagate_plant` | `POST /api/v1/plants/:id/propagations` | `plantId` **(required)**, `count` (1–10, default 1) |
290
350
  | `merge_plants` | `POST /api/v1/plants/merge` | `sourcePlantIds` **(required,** 1–200**)**, plus **exactly one of** `survivorPlantId` or `createNewFrom` |
291
351
  | `unmerge_plant` | `POST /api/v1/plants/:id/unmerge` | `plantId` **(required)** |
292
352
 
293
- `create_plant` needs an **existing** `plantTypeId` from `list_plant_types` creating a plant *type*
294
- is still a UI action, so if the species isn't in your catalog yet, that step happens in the app.
353
+ `create_plant`'s `plantTypeId` is now **optional**: pass an existing global type id (from
354
+ `list_plant_types`/`search_plant_types`) when the species is known, or omit it (or pass `null`) to
355
+ add an "unidentified" plant — `nickname` is then required, matching the app's plant form. Creating a
356
+ plant *type* is still a UI action, so if the species isn't in the catalog yet, that step happens in
357
+ the app; an unidentified plant can always be identified there later.
295
358
 
296
359
  `reference: true` creates an `EXTERNAL` plant: somebody else's, recorded only so lineage has a real
297
360
  node to point at. It gets no acquisition date, no care schedule, and no place in collection stats —
@@ -333,6 +396,17 @@ Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `noFerti
333
396
  `recentlyRepottedAvoidFertilizer`, `noRecentPhoto`. `wateringCycleMismatch` is **not** dismissible —
334
397
  it is a configuration contradiction, fixed by editing the plant rather than hidden.
335
398
 
399
+ ### Wishlist
400
+
401
+ | Tool | Endpoint | Arguments |
402
+ |---|---|---|
403
+ | `add_to_wishlist` | `POST /api/v1/wishlist` | `plantTypeId` or `name` — at least one required |
404
+ | `add_wishlist_note` | `POST /api/v1/wishlist/:id/notes` | `wishId` **(required)**, `text` **(required,** 1–500 chars**)** |
405
+ | `remove_from_wishlist` | `DELETE /api/v1/wishlist/:id` | `wishId` **(required)** |
406
+
407
+ `add_to_wishlist` never creates a second wish for a type the caller already has — it returns the
408
+ existing one instead (`duplicate: true`), rather than erroring or silently doubling it up.
409
+
336
410
  ### Deleting events
337
411
 
338
412
  | Tool | Endpoint | Arguments |
@@ -340,7 +414,9 @@ it is a configuration contradiction, fixed by editing the plant rather than hidd
340
414
  | `delete_watering_event` | `DELETE /api/v1/plants/:id/watering-events/:eventId` | `plantId`, `eventId` — both **required** |
341
415
  | `delete_fertilization_event` | `DELETE /api/v1/plants/:id/fertilization-events/:eventId` | `plantId`, `eventId` — both **required** |
342
416
 
343
- **These are the only destructive tools in this server, and neither can be undone.** Take `eventId`
417
+ **These are the only tools that delete collection history, and neither can be undone.** (`remove_from_wishlist`
418
+ also deletes a row, but a wish carries no history — see **What this server deliberately cannot do**
419
+ above.) Take `eventId`
344
420
  from `list_watering_events` / `list_fertilization_events` / `list_care_events`. For a reservoir or
345
421
  hydro plant, `delete_watering_event` deletes its refill/top-up event instead — the same routing
346
422
  `record_watering` uses on the write side.
@@ -372,8 +448,8 @@ includes `admin`.
372
448
 
373
449
  | Tool | Endpoint | Arguments |
374
450
  |---|---|---|
375
- | `admin_list_plant_types` | `GET /api/v1/admin/plant-types` | all optional: `status` (`UNVERIFIED`\|`VERIFIED`\|`MERGED`, defaults to every non-MERGED type), `search` (names + botanical), `genus`, `parentId` (a species-level type's cultivar children), `missingField` (a field key from `admin_get_plant_type_field_schema` — only types missing it), `hasOpenProposals`, `limit` (1–200), `offset` |
376
- | `admin_get_plant_type` | `GET /api/v1/admin/plant-types/:id` | `id` **(required)** — full detail: identity/status, parent/children, names, own/inherited/effective values, plant/user counts, open proposals, open duplicate candidates, image count, recent change log |
451
+ | `admin_list_plant_types` | `GET /api/v1/admin/plant-types` | all optional: `status` (`UNVERIFIED`\|`VERIFIED`\|`MERGED`, defaults to every live type — excludes both `MERGED` and `REJECTED`; a `REJECTED` type has no filter value of its own here yet), `search` (names + botanical), `genus`, `parentId` (a species-level type's cultivar children), `missingField` (a field key from `admin_get_plant_type_field_schema` — only types missing it), `hasOpenProposals`, `limit` (1–200), `offset` |
452
+ | `admin_get_plant_type` | `GET /api/v1/admin/plant-types/:id` | `id` **(required)** — full detail: identity/status (including `REJECTED`, for an admin-rejected user-created type — see `mergedIntoId`), parent/children, names, own/inherited/effective values, plant/user counts, open proposals, open duplicate candidates, image count, recent change log |
377
453
  | `admin_get_plant_type_field_schema` | `GET /api/v1/admin/plant-types/fields` | none — every guidance field's kind, bounds, allowed values, and what a scale value or unit means |
378
454
  | `admin_list_duplicate_candidates` | `GET /api/v1/admin/plant-types/duplicates` | `status` (`OPEN`\|`DISMISSED`\|`MERGED`, defaults to `OPEN`), `limit` (1–200), `offset` |
379
455
  | `admin_list_plant_type_proposals` | `GET /api/v1/admin/plant-types/proposals` | `status` (defaults to `PENDING`), `origin` (`user`\|`migration`), `typeId`, `limit` (1–200), `offset` |
@@ -490,6 +566,34 @@ can act on them or explain them to you:
490
566
 
491
567
  A non-JSON body is passed through as raw text rather than being swallowed.
492
568
 
569
+ ## Breaking changes in 2.0.0
570
+
571
+ Plant identity moved from a per-user catalog to a shared, community-maintained one. If your client
572
+ (or a cached tool call) held onto a `plantTypeId` from before this release, it needs re-resolving —
573
+ the id spaces are unrelated. The full rationale is in the app's `docs/mcp-server.md`; the mechanical
574
+ changes:
575
+
576
+ - **`plantTypeId` means a global catalog id everywhere it appears** (`list_plants`, `get_plant`,
577
+ `create_plant`, `update_plant`) — not the old per-user type id. Re-resolve any id you cached before
578
+ this release via `list_plant_types`/`search_plant_types`.
579
+ - **`list_plant_types` now serves the global catalog**, not the caller's own types — every caller
580
+ sees the same rows now, differing only in `viewerPlantsCount`.
581
+ - **New: `search_plant_types` and `get_plant_type`** — see **Catalogs** above.
582
+ - **`plantTypeId` is now optional on `create_plant` and `update_plant`** — omit it (or pass `null`)
583
+ for an "unidentified" plant. `nickname` becomes required whenever the plant ends up with no type.
584
+ - **`get_plant`'s `plantType` field is replaced by `type`** — see its row above for the new shape
585
+ (`plantTypeId`, `nickname`, `displayName`, `botanicalName`, `typeStatus`, `isUnidentified`).
586
+ `list_plants` rows changed the same way.
587
+ - **`get_plant` gained `typeFacts`** (the type's effective light/water/feeding need, toxicity,
588
+ climate, growth, substrate/propagation, family/native region).
589
+ - **`get_plant`'s six schedule fields are no longer raw values** — each is now
590
+ `{ value, source, typeValue }`. A consumer reading e.g. `plant.wateringFrequencySummerDays` as a
591
+ number directly will break; read `.value` instead.
592
+ - **`update_plant_care` no longer accepts `sunRequirement`/`waterRequirement`** — both are type-only
593
+ facts now.
594
+ - **`null` on `update_plant_care`'s watering/fertilizing-cycle fields now means "follow the type"**,
595
+ not "no schedule" — see that tool's row above.
596
+
493
597
  ## Versioning
494
598
 
495
599
  Semver against the **tool surface**, which is this package's public API:
package/dist/index.js CHANGED
@@ -131,16 +131,16 @@ server.tool("list_plants", "List the user's plants, with optional filters. Retur
131
131
  .describe("Filter by plant status. EXTERNAL is a reference plant: somebody else's plant, recorded only as an anchor for propagation lineage — never cared for and not part of the collection's totals."),
132
132
  lifecycle: z.enum(["PROPAGATING", "ESTABLISHED"]).optional().describe("Filter by lifecycle stage."),
133
133
  locationId: z.number().int().optional().describe("Filter by location id."),
134
- plantTypeId: z.number().int().optional().describe("Filter by plant type (species) id."),
134
+ plantTypeId: z.number().int().optional().describe("Filter by global plant type id, from list_plant_types/search_plant_types/get_plant."),
135
135
  soilId: z.number().int().optional().describe("Filter by soil id."),
136
136
  fertilizerId: z.number().int().optional().describe("Filter by fertilizer id."),
137
137
  tag: z.string().optional().describe("Filter by exact tag (case-sensitive)."),
138
- search: z.string().optional().describe("Free-text search over plant type name / notes."),
138
+ search: z.string().optional().describe("Free-text search over the plant's nickname, its type's names, and notes."),
139
139
  limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
140
140
  offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
141
- sort: z.string().optional().describe("Sort key, e.g. 'name', 'createdAt', 'updatedAt'."),
141
+ 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."),
142
142
  }, async (args) => apiGet("/api/v1/plants", args));
143
- server.tool("get_plant", "Get one plant with full detail: catalogs (type, location, soil, fertilizer), care config, and recent event summaries.", { id: z.number().int().describe("Plant id.") }, async ({ id }) => apiGet(`/api/v1/plants/${id}`));
143
+ 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}`));
144
144
  // --- Events connected to a plant -------------------------------------------
145
145
  const eventArgs = {
146
146
  id: z.number().int().describe("Plant id."),
@@ -234,24 +234,24 @@ server.tool("record_fertilization", "WRITE: log a fertilization for one or more
234
234
  .describe("Strength as a percentage of the base dose; defaults to each plant's own."),
235
235
  fertilizedAt: optionalPlantDate(""),
236
236
  }, async (args) => apiSend("POST", "/api/v1/care/fertilization", { ...args }));
237
- server.tool("update_plant_care", "WRITE: edit a plant's care schedule — watering mode, days/labels, sun and water requirement, fertilizing mode, cycles and percent, and the watering/fertilizing notes. Omitted fields keep their current value; null clears a notes/label field. Watering fields other than sunRequirement 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. Use this (not update_plant) to fix a 'wateringCycleMismatch' recommendation where the mode itself is wrong. Requires a write-scoped API key.", {
237
+ 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.", {
238
238
  plantId: z.number().int().positive().describe("Plant id."),
239
239
  wateringMode: z.enum(["scheduled", "reservoir", "hydro"]).optional().describe("How this plant is watered: on a schedule, from a reservoir, or in water/hydro culture."),
240
240
  fertilizingMode: z.enum(["scheduled", "with_watering"]).optional().describe("How this plant is fertilized: on its own cycle, or automatically with every watering."),
241
- sunRequirement: z.number().int().min(1).max(4).optional().describe("1 (low light) to 4 (full sun)."),
242
- waterRequirement: z.number().int().min(1).max(3).optional().describe("1 (low) to 3 (high). Ignored unless watering is scheduled."),
243
- wateringFrequencySummer: z.string().max(50).nullable().optional().describe("Free-text summer watering label, e.g. 'twice a week'. Ignored unless watering is scheduled."),
244
- wateringFrequencySummerDays: z.number().int().min(1).max(365).optional().describe("Summer watering interval in days. Ignored unless watering is scheduled."),
245
- wateringFrequencyWinter: z.string().max(50).nullable().optional().describe("Free-text winter watering label. Ignored unless watering is scheduled."),
246
- wateringFrequencyWinterDays: z.number().int().min(1).max(365).optional().describe("Winter watering interval in days. Ignored unless watering is scheduled."),
241
+ wateringFrequencySummer: z.string().max(50).nullable().optional().describe("Free-text summer watering label, e.g. 'twice a week'. null follows the type's guidance. Ignored unless watering is scheduled."),
242
+ wateringFrequencySummerDays: z.number().int().min(1).max(365).nullable().optional().describe("Summer watering interval in days. null follows the type's suggested days. Ignored unless watering is scheduled."),
243
+ wateringFrequencyWinter: z.string().max(50).nullable().optional().describe("Free-text winter watering label. null follows the type's guidance. Ignored unless watering is scheduled."),
244
+ wateringFrequencyWinterDays: z.number().int().min(1).max(365).nullable().optional().describe("Winter watering interval in days. null follows the type's suggested days. Ignored unless watering is scheduled."),
247
245
  wateringNotes: z.string().max(500).nullable().optional().describe("Free-text watering notes. Ignored unless watering is scheduled."),
248
- fertilizingCycleSummerWeeks: z.number().int().min(0).max(52).optional().describe("Summer fertilizing interval in weeks. Ignored while fertilizing is 'with every watering'."),
249
- fertilizingCycleWinterWeeks: z.number().int().min(0).max(52).optional().describe("Winter fertilizing interval in weeks. Ignored while fertilizing is 'with every watering'."),
246
+ fertilizingCycleSummerWeeks: z.number().int().min(0).max(52).nullable().optional().describe("Summer fertilizing interval in weeks. null follows the type's suggested weeks. Ignored while fertilizing is 'with every watering'."),
247
+ 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'."),
250
248
  fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose."),
251
249
  fertilizingNotes: z.string().max(500).nullable().optional().describe("Free-text fertilizing notes."),
252
250
  }, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}/care`, body));
253
- server.tool("update_plant", "WRITE: edit a plant's 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. Requires a write-scoped API key.", {
251
+ 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.", {
254
252
  plantId: z.number().int().positive().describe("Plant id."),
253
+ plantTypeId: z.number().int().positive().nullable().optional().describe("New global plant type id, from list_plant_types/search_plant_types/get_plant. A MERGED id transparently resolves to its survivor. null makes the plant unidentified — nickname is then required."),
254
+ nickname: z.string().max(100).nullable().optional().describe("The plant's own name, shown instead of the type's name when set. Required when the plant has (or is becoming) unidentified."),
255
255
  locationId: z.number().int().positive().nullable().optional().describe("New location id, from list_locations. null unassigns it."),
256
256
  soilId: z.number().int().positive().nullable().optional().describe("New soil id, from list_soils. null unassigns it."),
257
257
  fertilizerId: z.number().int().positive().nullable().optional().describe("New fertilizer id, from list_fertilizers. null unassigns it."),
@@ -308,8 +308,9 @@ server.tool("delete_fertilization_event", "WRITE: delete a fertilization event f
308
308
  eventId: z.number().int().positive().describe("Event id, from list_fertilization_events or list_care_events."),
309
309
  }, async ({ plantId, eventId }) => apiSend("DELETE", `/api/v1/plants/${plantId}/fertilization-events/${eventId}`));
310
310
  // --- Plant lifecycle (writes) ------------------------------------------------
311
- server.tool("create_plant", "WRITE: add a new plant to the collection. plantTypeId is required and must be an existing type — call list_plant_types first and pick the matching one; creating a new plant type is not possible here and stays a UI action. Set reference: true to record somebody else's plant (status EXTERNAL): it is not one of the user's own plants, gets no acquisition date and no care schedule, and is excluded from collection stats — its purpose is to be a real node that propagation lineage can point at. Requires a write-scoped API key.", {
312
- plantTypeId: z.number().int().positive().describe("Plant type id, from list_plant_types."),
311
+ server.tool("create_plant", "WRITE: add a new plant to the collection. plantTypeId is now optional (omit it, or pass null, to create an 'unidentified' plant) — call list_plant_types/search_plant_types first and pick the matching one when the species is known; creating a new plant type is not possible here and stays a UI action. nickname is required whenever plantTypeId is omitted/null, matching the app's plant form. Set reference: true to record somebody else's plant (status EXTERNAL): it is not one of the user's own plants, gets no acquisition date and no care schedule, and is excluded from collection stats — its purpose is to be a real node that propagation lineage can point at. Requires a write-scoped API key.", {
312
+ plantTypeId: z.number().int().positive().nullable().optional().describe("Global plant type id, from list_plant_types/search_plant_types. Omit (or pass null) for an unidentified plant — a MERGED id transparently resolves to its survivor."),
313
+ nickname: z.string().max(100).optional().describe("The plant's own name. Required when plantTypeId is omitted or null."),
313
314
  reference: z.boolean().optional().describe("true creates an EXTERNAL reference plant (somebody else's, a lineage anchor only). Defaults to false: a LIVING plant of the user's own."),
314
315
  parentPlantId: z.number().int().positive().optional().describe("Optional: the plant this one was propagated from."),
315
316
  }, async (body) => apiSend("POST", "/api/v1/plants", { ...body }));
@@ -325,9 +326,46 @@ server.tool("merge_plants", "WRITE: merge several living plants into one — for
325
326
  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.", {
326
327
  plantId: z.number().int().positive().describe("The MERGED plant to restore."),
327
328
  }, async ({ plantId }) => apiSend("POST", `/api/v1/plants/${plantId}/unmerge`));
329
+ // --- Wishlist ----------------------------------------------------------
330
+ /**
331
+ * The caller's personal wishlist (R2-15) — plants they want but don't own
332
+ * yet, either a global catalog type (`plantTypeId`) or a free-text name
333
+ * fallback for a species not in the catalog. Unlike sales/trades, wishes
334
+ * are never exposed-but-excluded: they're the caller's own data, about
335
+ * their own collection, so there's no second-person consent concern.
336
+ */
337
+ server.tool("list_wishlist", "List the caller's wishlist: each wish's plant type (when set) with its display/botanical name, or a free-text name fallback, plus notes and how many LIVING plants of that type the caller already owns.", async () => apiGet("/api/v1/wishlist"));
338
+ server.tool("add_to_wishlist", "WRITE: add a wish — a global catalog type (plantTypeId, from list_plant_types/search results) or a free-text name for a species not in the catalog yet. At least one is required. Never creates a second wish for a type the caller already has — returns the existing one instead (duplicate: true). Requires a write-scoped API key.", {
339
+ plantTypeId: z.number().int().positive().optional().describe("An existing global plant type id."),
340
+ name: z.string().max(100).optional().describe("Free-text name fallback, for a species not yet in the catalog."),
341
+ }, async ({ plantTypeId, name }) => apiSend("POST", "/api/v1/wishlist", { plantTypeId, name }));
342
+ server.tool("add_wishlist_note", "WRITE: add a dated note to a wish — price, where to get it, a shop link, etc. Requires a write-scoped API key.", {
343
+ wishId: z.number().int().positive().describe("Wish id, from list_wishlist."),
344
+ text: z.string().min(1).max(500).describe("The note text."),
345
+ }, async ({ wishId, text }) => apiSend("POST", `/api/v1/wishlist/${wishId}/notes`, { text }));
346
+ server.tool("remove_from_wishlist", "WRITE: remove a wish (e.g. once it's been fulfilled by a new plant). Requires a write-scoped API key.", {
347
+ wishId: z.number().int().positive().describe("Wish id, from list_wishlist."),
348
+ }, async ({ wishId }) => apiSend("DELETE", `/api/v1/wishlist/${wishId}`));
328
349
  // --- Catalogs (for resolving filter ids) -----------------------------------
329
350
  server.tool("list_locations", "List the user's locations.", async () => apiGet("/api/v1/locations"));
330
- server.tool("list_plant_types", "List the user's plant types (species taxonomy).", async () => apiGet("/api/v1/plant-types"));
351
+ /**
352
+ * R2-12: the global plant type catalog, shared by every user — not a
353
+ * per-user list any more. `list_plant_types` and `search_plant_types` are
354
+ * deliberately the same underlying endpoint/reader (`GET /api/v1/plant-types`
355
+ * — see `docs/mcp-server.md`'s "Catalog reads" section): browsing and
356
+ * searching are the same fuzzy-filtered, paged list, just with `query`
357
+ * required for one and optional for the other, so a model reaching for
358
+ * either gets the same result shape and paging behavior.
359
+ */
360
+ const publicPlantTypeListArgs = {
361
+ status: z.enum(["UNVERIFIED", "VERIFIED"]).optional().describe("Filter by review status. Defaults to every live type (both)."),
362
+ inMyCollection: z.boolean().optional().describe("true: only types the caller has at least one LIVING plant of."),
363
+ limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
364
+ offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
365
+ };
366
+ server.tool("list_plant_types", "Browse the shared global plant type catalog — every plant's species/cultivar entry, one catalog for the whole app. Pass query to search by name/botanical; omit it to just browse (optionally filtered by status/inMyCollection). Compact rows only — no full field values or facts; use get_plant_type for one type's fact sheet. A MERGED type never appears here — see get_plant_type for how an id that was since merged resolves.", { query: z.string().optional().describe("Fuzzy search over names (all locales) and the botanical name."), ...publicPlantTypeListArgs }, async (args) => apiGet("/api/v1/plant-types", { ...args, inMyCollection: args.inMyCollection?.toString() }));
367
+ server.tool("search_plant_types", "Fuzzy search the shared global plant type catalog by name (all locales) or botanical name — the search-first counterpart to list_plant_types (same endpoint, query required here). Use this when the caller named a species/plant and you need its id, e.g. before create_plant or update_plant.", { query: z.string().min(1).describe("Search text — a common name in any language, or a botanical name."), ...publicPlantTypeListArgs }, async (args) => apiGet("/api/v1/plant-types", { ...args, inMyCollection: args.inMyCollection?.toString() }));
368
+ server.tool("get_plant_type", "Get one global plant type's fact sheet: identity and status, names by locale, parent/cultivar children, effective catalog facts (light, water, feeding need, toxicity, climate, growth, substrate/propagation, family/native region) with each field's source ('own' vs. 'inherited' from an ancestor), approved image URLs, and how many of the caller's own LIVING plants use it. A MERGED id transparently resolves to its survivor (mergedIntoId is still reported) rather than returning a redirect stub — unlike the admin equivalent.", { id: z.number().int().describe("Global plant type id, from list_plant_types/search_plant_types/get_plant.") }, async ({ id }) => apiGet(`/api/v1/plant-types/${id}`));
331
369
  server.tool("list_soils", "List the user's soils.", async () => apiGet("/api/v1/soils"));
332
370
  server.tool("list_fertilizers", "List the user's fertilizers.", async () => apiGet("/api/v1/fertilizers"));
333
371
  // --- Admin: global plant type catalog (admin-scoped keys only) -------------
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sebamomann/plants-mcp",
3
- "version": "1.9.0",
4
- "description": "MCP server for the Sprig plant app: 50 tools to read a plant collection, log care, edit a plant, and add, propagate or merge plants. Read-only by default; writes need a write-scoped API key. Two tools can delete a watering/fertilization event; nothing else is destructive. 17 more tools read, edit, merge and review the shared global plant type catalog for an admin-scoped key.",
3
+ "version": "2.0.0",
4
+ "description": "MCP server for the Sprig plant app: 56 tools to read a plant collection, browse the shared global plant type catalog, log care, edit a plant, add, propagate or merge plants, and manage a wishlist. Read-only by default; writes need a write-scoped API key. Two tools can delete a watering/fertilization event; nothing else deletes collection history. 17 more tools read, edit, merge and review the shared global plant type catalog for an admin-scoped key.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "sebamomann <github@sebamomann.de>",