@sebamomann/plants-mcp 1.9.0 → 2.5.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 +148 -0
- package/README.md +292 -54
- package/dist/index.js +177 -17
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,154 @@
|
|
|
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.5.0 — 2026-09-16
|
|
7
|
+
|
|
8
|
+
**Past problem diagnoses.** 76 tools registered overall (27 read, 31 write, 18 admin). A **minor**
|
|
9
|
+
bump — one new tool, nothing renamed or removed:
|
|
10
|
+
|
|
11
|
+
- `list_diagnoses` — a plant's guided problem diagnoses ("Problem eingrenzen"), newest first, with
|
|
12
|
+
the symptom, every question and the answer given, the ranked possible causes, the action the user
|
|
13
|
+
picked, and the resolution. Active and resolved runs alike, since a resolved run stays readable by
|
|
14
|
+
design. Stored i18n keys are resolved to plain English for the caller. Running the flow itself
|
|
15
|
+
stays UI-only — see `docs/mcp-server.md`.
|
|
16
|
+
|
|
17
|
+
## 2.4.0 — 2026-09-16
|
|
18
|
+
|
|
19
|
+
**Admin dashboard status.** 75 tools registered overall (26 read, 31 write, 18 admin). A **minor**
|
|
20
|
+
bump — one new tool, nothing renamed or removed:
|
|
21
|
+
|
|
22
|
+
- `admin_get_dashboard_status` — the same aggregate the app's own `/admin` page shows in one call:
|
|
23
|
+
every review queue's count and the age of its oldest item, catalog health (verified/unverified
|
|
24
|
+
split, types with no care values, orphan cultivars, types with no plants, low-completeness types,
|
|
25
|
+
unidentified plants), weekly activity (new plants, care events logged, contributions
|
|
26
|
+
submitted/decided), and operational facts (push-enabled users, pending email verifications,
|
|
27
|
+
active API keys, applied migration count — counts only, never a secret). No arguments. Sits
|
|
28
|
+
outside the catalog surface below (it isn't under `/api/v1/admin/plant-types/*`) — see
|
|
29
|
+
`docs/mcp-server.md`'s "Admin dashboard status".
|
|
30
|
+
|
|
31
|
+
## 2.3.0 — 2026-09-16
|
|
32
|
+
|
|
33
|
+
**Write parity: repotting, refills, hydro events, snoozing, lifecycle status, catalog
|
|
34
|
+
contributions, and notifications.** 74 tools registered overall (26 read, 31 write, 17 admin). A
|
|
35
|
+
**minor** bump — nine new tools, nothing renamed or removed:
|
|
36
|
+
|
|
37
|
+
- `record_repotting` — log a repotting (pot/soil/size), re-deriving the plant's soil and syncing
|
|
38
|
+
care mode on a self-watering pot change, the same as the app's own repotting form.
|
|
39
|
+
- `record_refill` — log a reservoir refill with fertilizer/percent, for a `reservoir`-mode plant.
|
|
40
|
+
- `record_hydro_event` — log a hydro-culture top-up or solution change.
|
|
41
|
+
- `snooze_care` — postpone a plant's next watering or fertilizing due date by a fixed duration (1,
|
|
42
|
+
3, 7, or 14 days).
|
|
43
|
+
- `set_plant_status` — set a plant's status to `LIVING`, `DEAD`, or `LOST` (deliberately narrower
|
|
44
|
+
than the app's full status range — see `docs/mcp-server.md`'s "Lifecycle status changes are a
|
|
45
|
+
safe subset").
|
|
46
|
+
- `set_plant_reference` — flip a plant between `LIVING` and `EXTERNAL` (reference-only);
|
|
47
|
+
reversible.
|
|
48
|
+
- `create_plant_type` — the user-facing counterpart to `admin_create_plant_type`: add a
|
|
49
|
+
species/cultivar the catalog is missing, always `UNVERIFIED`. Runs the plant-type picker's
|
|
50
|
+
duplicate check server-side (there's no client screen to have shown it first) — a likely
|
|
51
|
+
duplicate blocks creation (`{ created: false, duplicates }`) unless `confirmDespiteDuplicates:
|
|
52
|
+
true`. `propose_plant_type_change` stays on the roadmap; it needs its own design pass.
|
|
53
|
+
- `list_notifications` — the caller's notification centre, newest first, cursor-paginated.
|
|
54
|
+
Sale/trade notifications are always excluded, the same permanent exclusion
|
|
55
|
+
`list_recent_activity` applies.
|
|
56
|
+
- `mark_notifications_read` — mark every unread notification read.
|
|
57
|
+
|
|
58
|
+
## 2.2.0 — 2026-09-16
|
|
59
|
+
|
|
60
|
+
**Catalog creation, pots, and mode-agnostic vacation status.** 65 tools registered overall (25
|
|
61
|
+
read, 23 write, 17 admin). A **minor** bump — six new tools, nothing renamed or removed:
|
|
62
|
+
|
|
63
|
+
- `create_location` — create a location, optionally nested under an owned parent.
|
|
64
|
+
- `create_soil` — create a soil mix, optionally with components (each upserted by name).
|
|
65
|
+
- `create_fertilizer` — create a fertilizer, with an optional NPK label and base dose.
|
|
66
|
+
- `list_pots` — the caller's own pots plus the shared default pots, with reference links and
|
|
67
|
+
recorded sizes.
|
|
68
|
+
- `create_pot` — create a pot, optionally with reference links. Pot *sizes* still have no create
|
|
69
|
+
tool.
|
|
70
|
+
- `get_vacation_status` — whether the caller has a current or upcoming vacation in **either** mode
|
|
71
|
+
(`sitter` or `shift`), with just id/mode/date-range/active-state/plant-count — the mode-agnostic
|
|
72
|
+
counterpart `sitter_briefing` never provided (that tool only ever reports a `sitter`-mode
|
|
73
|
+
vacation's full detail).
|
|
74
|
+
|
|
75
|
+
The four create tools reuse the same cores the plant form's "create inline" pickers use
|
|
76
|
+
(`app/_lib/plants/resourceCreation.ts`), so a location/soil/fertilizer/pot created over MCP gets
|
|
77
|
+
identical validation and duplicate-name handling to one created in the app.
|
|
78
|
+
|
|
79
|
+
## 2.1.0 — 2026-09-16
|
|
80
|
+
|
|
81
|
+
**Watch a plant type.** 59 tools registered overall (23 read, 19 write, 17 admin). A **minor**
|
|
82
|
+
bump — three new tools, nothing renamed or removed:
|
|
83
|
+
|
|
84
|
+
- `list_watched_types` — the caller's "watch a type" subscriptions: type id, display/botanical
|
|
85
|
+
name, status.
|
|
86
|
+
- `watch_type` — subscribe to a global catalog type. Never creates a second watch for a type
|
|
87
|
+
already watched (`duplicate: true`), the same convention as `add_to_wishlist`.
|
|
88
|
+
- `unwatch_type` — remove a subscription. Not counted as a destructive delete, the same reasoning
|
|
89
|
+
as `remove_from_wishlist` — a watch is a personal subscription with no history to lose.
|
|
90
|
+
|
|
91
|
+
A watcher is notified **in-app only** (the Neuigkeiten centre) whenever a watched type's fields,
|
|
92
|
+
identity, names, verification status or approved photos change — there is no MCP read for
|
|
93
|
+
notifications yet (see `docs/mcp-server.md`'s roadmap).
|
|
94
|
+
|
|
95
|
+
## 2.0.0 — 2026-09-15
|
|
96
|
+
|
|
97
|
+
**Plant identity moves to a shared, community-maintained global catalog.** 56 tools registered
|
|
98
|
+
overall (22 read, 17 write, 17 admin). This is a **major** bump — see `docs/mcp-server.md`'s
|
|
99
|
+
"Breaking changes in 2.0.0" section for the full list; summary:
|
|
100
|
+
|
|
101
|
+
- **`plantTypeId` is now a global catalog id everywhere it appears** (`list_plants`, `get_plant`,
|
|
102
|
+
`create_plant`, `update_plant`) — a legacy per-user type id cached before this release does not
|
|
103
|
+
refer to the same row. Re-resolve any cached id via `list_plant_types`/`search_plant_types`.
|
|
104
|
+
- **`list_plant_types` now serves the shared global catalog**, not a per-user list.
|
|
105
|
+
- **New tools:**
|
|
106
|
+
- `search_plant_types` — fuzzy search the global catalog by name (any locale) or botanical name.
|
|
107
|
+
Same underlying endpoint as `list_plant_types`; `query` is required here, optional there.
|
|
108
|
+
- `get_plant_type` — one type's fact sheet: identity, names by locale, parent/cultivar children,
|
|
109
|
+
effective catalog facts with each field's source (`"own"`/`"inherited"`), approved image URLs,
|
|
110
|
+
and the caller's own plant count. A `MERGED` id transparently resolves to its survivor.
|
|
111
|
+
- **`create_plant`'s `plantTypeId` is now optional** — omit it (or pass `null`) to add an
|
|
112
|
+
"unidentified" plant; `nickname` is then required, matching the app's plant form.
|
|
113
|
+
- **`update_plant` gained `plantTypeId` and `nickname`** — re-identify a plant, or make it
|
|
114
|
+
unidentified (`plantTypeId: null` + a `nickname`).
|
|
115
|
+
- **`get_plant`'s `plantType` field is replaced by `type`** —
|
|
116
|
+
`{ plantTypeId, nickname, displayName, botanicalName, typeStatus, isUnidentified }`. `list_plants`
|
|
117
|
+
rows changed the same way.
|
|
118
|
+
- **`get_plant` gained `typeFacts`** — the type's effective light/water/feeding need, toxicity,
|
|
119
|
+
climate, growth, substrate/propagation, and family/native region.
|
|
120
|
+
- **`get_plant`'s six schedule fields are no longer raw values** — each is now
|
|
121
|
+
`{ value, source: "plant"|"type"|"none", typeValue }`.
|
|
122
|
+
- **`update_plant_care` no longer accepts `sunRequirement`/`waterRequirement`** — both are type-only
|
|
123
|
+
facts now, read via `get_plant`'s `typeFacts` or `get_plant_type`.
|
|
124
|
+
- **`null` on `update_plant_care`'s watering/fertilizing-cycle fields now means "follow the type"**
|
|
125
|
+
instead of "no schedule" — the tool's zod schema now accepts `null` for those four fields, which
|
|
126
|
+
it previously rejected. A value equal to the type's value is stored as `null` too, so the plant
|
|
127
|
+
keeps following later changes to the type; `update_plant` switching `plantTypeId` moves values
|
|
128
|
+
that followed the old type onto the new one (or, to no type, writes them in).
|
|
129
|
+
- **`list_plants`'s `sort=commonName` is renamed `sort=displayName`** (resolved in memory — no
|
|
130
|
+
longer a database column) and its `search` now matches the plant's nickname and its type's names
|
|
131
|
+
in every locale, not a legacy `PlantType.commonName` column.
|
|
132
|
+
- **`GlobalPlantTypeStatus` gained a `REJECTED` value** — an admin can reject a user-created
|
|
133
|
+
`UNVERIFIED` type in the app (required note, plus a merge/unlink outcome for its plants); the
|
|
134
|
+
row persists, analogous to `MERGED`. `list_plant_types`/`search_plant_types` never return one;
|
|
135
|
+
`get_plant_type` on a rejected id behaves like `MERGED` (redirects when the outcome was a merge).
|
|
136
|
+
`admin_get_plant_type`'s `status` can now be `"REJECTED"`. No new tool triggers this yet — see
|
|
137
|
+
`docs/mcp-server.md`'s roadmap (`admin_reject_plant_type`).
|
|
138
|
+
|
|
139
|
+
## 1.10.0 — 2026-09-15
|
|
140
|
+
|
|
141
|
+
**Wishlist tools.** Four tools total — 54 registered overall (20 read, 17 write, 17 admin):
|
|
142
|
+
|
|
143
|
+
- `list_wishlist` — the caller's wishlist: each wish's plant type (display/botanical name,
|
|
144
|
+
`typeStatus`) when set, or a free-text `name` fallback, plus dated `notes` and
|
|
145
|
+
`ownedLivingCount` (how many `LIVING` plants of that type the caller already has).
|
|
146
|
+
- `add_to_wishlist` (write) — add a wish by `plantTypeId` or free-text `name`. Never creates a
|
|
147
|
+
second wish for a type the caller already has — returns the existing one instead
|
|
148
|
+
(`duplicate: true`).
|
|
149
|
+
- `add_wishlist_note` (write) — add a dated note to a wish (price, where to get it, a shop link).
|
|
150
|
+
- `remove_from_wishlist` (write) — remove a wish. Not counted as a "destructive" tool in the sense
|
|
151
|
+
`delete_watering_event`/`delete_fertilization_event` are — a wish carries no collection history,
|
|
152
|
+
and is trivially re-added.
|
|
153
|
+
|
|
6
154
|
## 1.9.0 — 2026-09-15
|
|
7
155
|
|
|
8
156
|
**Admin keys can now read, directly edit, merge, and review the shared global plant type catalog.**
|
package/README.md
CHANGED
|
@@ -60,17 +60,24 @@ 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
|
+
**76 tools** over your collection: 27 that read it, 31 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
|
-
shared global plant type catalog, restructuring its genus/species/cultivar tree,
|
|
66
|
-
|
|
67
|
-
that
|
|
68
|
-
|
|
69
|
-
Reads cover plants, watering and fertilization history, the
|
|
70
|
-
health entries, the derived care schedule, collection-wide
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
shared global plant type catalog, restructuring its genus/species/cultivar tree, reading, directly
|
|
66
|
+
editing, merging, and reviewing proposals and duplicate candidates in that catalog, and (7 read-only,
|
|
67
|
+
11 that write) checking the instance's own operational status. See **Admin tools** below.
|
|
68
|
+
|
|
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, watched types, notifications, vacation status, and the lookup catalogs
|
|
72
|
+
(including pots). Writes cover recording waterings, fertilizings, repottings, reservoir refills and
|
|
73
|
+
hydro events, snoozing a due date, health notes, dismissing care recommendations, editing a plant's
|
|
74
|
+
identity/care schedule/settings/lifecycle status, adding a plant (identified or not), propagating
|
|
75
|
+
one, merging plants together and reversing that merge, deleting a watering or fertilization event,
|
|
76
|
+
managing the wishlist (add, note, remove), watching or unwatching a plant type, marking notifications
|
|
77
|
+
read, creating a location/soil/fertilizer/pot, and contributing a new species to the shared catalog.
|
|
78
|
+
|
|
79
|
+
**Plant identity moved to a shared, community-maintained catalog in 2.0.0** — see **Breaking changes
|
|
80
|
+
in 2.0.0** below before upgrading a client that stored a `plantTypeId`.
|
|
74
81
|
|
|
75
82
|
**Deleting a plant, a photo, or a catalog entry is not possible from here** — that stays UI-only.
|
|
76
83
|
`delete_watering_event` and `delete_fertilization_event` are the two exceptions (see **What this
|
|
@@ -102,11 +109,11 @@ healthy server, not a hang — press Ctrl-C.
|
|
|
102
109
|
|
|
103
110
|
The scope lives on the API key, is enforced by the app, and cannot be widened from this side.
|
|
104
111
|
|
|
105
|
-
- **`read`** — on every key. Gates all
|
|
106
|
-
- **`write`** — opt-in when you create the key. Gates the
|
|
112
|
+
- **`read`** — on every key. Gates all 27 read tools.
|
|
113
|
+
- **`write`** — opt-in when you create the key. Gates the 31 write tools.
|
|
107
114
|
- **`admin`** — only offered when the key's creator is themselves an admin, and only while they
|
|
108
115
|
still are one (the app re-checks this on every admin-tool call, not just at key creation). Gates
|
|
109
|
-
the
|
|
116
|
+
the 18 admin catalog tools — 7 read-only, 11 that write — see **Admin tools** below. `admin` alone
|
|
110
117
|
is enough for the write ones too (not also `write` — the three scopes are independent grants). An
|
|
111
118
|
admin's key is created with `read`+`write`+`admin` together, so it also works with every ordinary
|
|
112
119
|
tool.
|
|
@@ -124,11 +131,14 @@ point of the scope split.
|
|
|
124
131
|
|
|
125
132
|
Not oversights — deliberate limits:
|
|
126
133
|
|
|
127
|
-
- **No deletes, except two.** Deleting a plant, a photo, or a catalog entry
|
|
128
|
-
blast radius of a misunderstood delete there is a multi-year history, and
|
|
129
|
-
*status* change instead. `delete_watering_event` and
|
|
130
|
-
exceptions: a mis-logged event has no such reversible
|
|
131
|
-
and **cannot be undone**.
|
|
134
|
+
- **No deletes of collection history, except two.** Deleting a plant, a photo, or a catalog entry
|
|
135
|
+
stays UI-only — the blast radius of a misunderstood delete there is a multi-year history, and
|
|
136
|
+
retiring a plant is a *status* change instead. `delete_watering_event` and
|
|
137
|
+
`delete_fertilization_event` are the exceptions: a mis-logged event has no such reversible
|
|
138
|
+
alternative, so those two are destructive and **cannot be undone**. `remove_from_wishlist` and
|
|
139
|
+
`unwatch_type` are two more deletes, but not in the same sense — a wish or a watch is a personal
|
|
140
|
+
to-do item / subscription with no history to lose, trivially re-added with `add_to_wishlist` /
|
|
141
|
+
`watch_type` if removed by mistake.
|
|
132
142
|
- **No sales or trades.** Those records name a second person who never consented to your API key.
|
|
133
143
|
- **No share-link creation.** Minting a public URL for your collection is a decision for the UI.
|
|
134
144
|
- **No cross-user access.** Ownership comes from the key; a tool has no way to even express
|
|
@@ -143,17 +153,27 @@ Not oversights — deliberate limits:
|
|
|
143
153
|
| Tool | Endpoint | Arguments |
|
|
144
154
|
|---|---|---|
|
|
145
155
|
| `whoami` | `GET /api/v1/me` | none — who the key belongs to |
|
|
146
|
-
| `list_plants` | `GET /api/v1/plants` | all optional: `status`, `lifecycle`, `locationId`, `plantTypeId
|
|
147
|
-
| `get_plant` | `GET /api/v1/plants/:id` | `id` **(required)** — full detail: catalogs, care config, lineage, recent events |
|
|
156
|
+
| `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`) |
|
|
157
|
+
| `get_plant` | `GET /api/v1/plants/:id` | `id` **(required)** — full detail: identity (`type`), `typeFacts`, catalogs, care config, lineage, recent events |
|
|
148
158
|
|
|
149
159
|
`status` is one of `LIVING`, `DEAD`, `GIFTED`, `LOST`, `SOLD`, `TRADED`, `MERGED`, `SPLIT`,
|
|
150
160
|
`EXTERNAL`; `lifecycle` is `PROPAGATING` or `ESTABLISHED`. `EXTERNAL` is a **reference plant** —
|
|
151
161
|
somebody else's plant, recorded only as an anchor for propagation lineage. It is never cared for,
|
|
152
162
|
never appears in due/overdue care, and is excluded from `collection_stats`.
|
|
153
163
|
|
|
164
|
+
Each plant's identity is a `type` object (replacing the old `plantType`):
|
|
165
|
+
`{ plantTypeId, nickname, displayName, botanicalName, typeStatus, isUnidentified }`.
|
|
166
|
+
`plantTypeId` is `null` for an "unidentified" plant — one with no catalog type, just a `nickname`.
|
|
167
|
+
`get_plant` additionally returns `typeFacts` — the type's effective (inheritance-resolved) catalog
|
|
168
|
+
facts (light, water, feeding need, toxicity, climate, growth, substrate/propagation, family/native
|
|
169
|
+
region), keyed by the same field names `get_plant_type` uses — and its six schedule fields
|
|
170
|
+
(`wateringFrequencySummerDays`/`WinterDays`, `wateringFrequencySummer`/`Winter`,
|
|
171
|
+
`fertilizingCycleSummerWeeks`/`WinterWeeks`) as `{ value, source: "plant"|"type"|"none", typeValue }`
|
|
172
|
+
instead of a raw value — see **Breaking changes in 2.0.0** below.
|
|
173
|
+
|
|
154
174
|
### Per-plant history
|
|
155
175
|
|
|
156
|
-
All
|
|
176
|
+
All six take the same arguments: `id` **(required)**, `limit` (1–500), `offset`. Newest first.
|
|
157
177
|
|
|
158
178
|
| Tool | Endpoint | Returns |
|
|
159
179
|
|---|---|---|
|
|
@@ -162,6 +182,7 @@ All five take the same arguments: `id` **(required)**, `limit` (1–500), `offse
|
|
|
162
182
|
| `list_care_events` | `GET /api/v1/plants/:id/events` | Combined timeline — watering, fertilization, refill, hydro, potting, snoozes — each tagged with a `kind` |
|
|
163
183
|
| `list_photos` | `GET /api/v1/plants/:id/photos` | Photo **metadata** (urls, `takenAt`). Not the images themselves. |
|
|
164
184
|
| `list_health_entries` | `GET /api/v1/plants/:id/health` | Health and AI-analysis entries. Source of the `entryId` the health write tools take. |
|
|
185
|
+
| `list_diagnoses` | `GET /api/v1/plants/:id/diagnoses` | Past guided-diagnosis runs ("Problem eingrenzen"): symptom, Q&A, ranked possible causes, chosen action, AI opinion if any — resolved to plain English. |
|
|
165
186
|
|
|
166
187
|
### Care schedule
|
|
167
188
|
|
|
@@ -213,6 +234,7 @@ page.
|
|
|
213
234
|
| Tool | Endpoint | Arguments |
|
|
214
235
|
|---|---|---|
|
|
215
236
|
| `sitter_briefing` | `GET /api/v1/vacation/sitter-briefing` | `locationId` (optional) |
|
|
237
|
+
| `get_vacation_status` | `GET /api/v1/vacation/status` | none |
|
|
216
238
|
|
|
217
239
|
Printable care sheet for the caller's current or next `sitter`-mode vacation: per-plant sitter
|
|
218
240
|
instructions, watering notes, cycle and next-due state, and the day-by-day watering schedule for
|
|
@@ -220,23 +242,90 @@ the trip. Returns `{ hasVacation: false }` when there's no upcoming or running s
|
|
|
220
242
|
this tool never creates or modifies one. `locationId` narrows the plant list to one location; the
|
|
221
243
|
schedule always covers the whole vacation.
|
|
222
244
|
|
|
245
|
+
`get_vacation_status` is the lighter, mode-agnostic counterpart: it reports a current or upcoming
|
|
246
|
+
vacation in **either** mode (`sitter` or `shift`), with just `{ id, mode, startsOn, endsOn, isActive,
|
|
247
|
+
isUpcoming, plantCount }` — no per-plant detail or schedule. Use `sitter_briefing` for the full
|
|
248
|
+
sitter sheet; use this one just to know whether a vacation (of either mode) exists at all.
|
|
249
|
+
|
|
250
|
+
### Wishlist
|
|
251
|
+
|
|
252
|
+
| Tool | Endpoint | Arguments |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| `list_wishlist` | `GET /api/v1/wishlist` | none |
|
|
255
|
+
|
|
256
|
+
Plants the caller wants but doesn't own yet. Each entry carries `plantTypeId` (when it's a global
|
|
257
|
+
catalog type, with its display/botanical name and `typeStatus`) or a free-text `name` fallback for
|
|
258
|
+
a species not yet in the catalog, a `notes` array (dated, e.g. price or where to get it), and
|
|
259
|
+
`ownedLivingCount` — how many `LIVING` plants of that type the caller already has.
|
|
260
|
+
|
|
261
|
+
### Watched types
|
|
262
|
+
|
|
263
|
+
| Tool | Endpoint | Arguments |
|
|
264
|
+
|---|---|---|
|
|
265
|
+
| `list_watched_types` | `GET /api/v1/plant-type-watches` | none |
|
|
266
|
+
|
|
267
|
+
Global catalog types the caller subscribed to via the "Art beobachten" eye toggle (the wishlist
|
|
268
|
+
heart's sibling affordance). Each entry carries `plantTypeId`, `typeDisplayName`, `botanicalName`,
|
|
269
|
+
`cultivar` and `typeStatus`. A watcher gets notified in-app (the Neuigkeiten centre — see
|
|
270
|
+
**Notifications** below) whenever an admin edits the type's fields, identity or names, verifies it,
|
|
271
|
+
merges it into another type, or a new photo is approved for it.
|
|
272
|
+
|
|
273
|
+
### Notifications
|
|
274
|
+
|
|
275
|
+
| Tool | Endpoint | Arguments |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| `list_notifications` | `GET /api/v1/notifications` | `cursor` (optional, from a previous call's `nextCursor`) |
|
|
278
|
+
|
|
279
|
+
The caller's notification centre, newest first, cursor-paginated. Each item is `{ id, kind,
|
|
280
|
+
createdAt, readAt, href, messageKey, values, actor }` — `messageKey`/`values` are the same
|
|
281
|
+
i18n key + interpolation data the in-app page renders through next-intl, not pre-rendered text, so
|
|
282
|
+
the exact sentence depends on locale rather than being hardcoded here. `actor` (when the event has
|
|
283
|
+
one) is the user who triggered it — `{ id, username, avatarUrl }`. Sale/trade notifications
|
|
284
|
+
(`SALE_*` kinds) are always excluded, the same permanent exclusion `list_recent_activity` applies.
|
|
285
|
+
|
|
223
286
|
### Catalogs
|
|
224
287
|
|
|
225
|
-
For resolving the ids the filters take.
|
|
288
|
+
For resolving the ids the filters take.
|
|
226
289
|
|
|
227
|
-
| Tool | Endpoint |
|
|
228
|
-
|
|
229
|
-
| `list_locations` | `GET /api/v1/locations` |
|
|
230
|
-
| `list_plant_types` | `GET /api/v1/plant-types` |
|
|
231
|
-
| `
|
|
232
|
-
| `
|
|
290
|
+
| Tool | Endpoint | Arguments |
|
|
291
|
+
|---|---|---|
|
|
292
|
+
| `list_locations` | `GET /api/v1/locations` | none |
|
|
293
|
+
| `list_plant_types` | `GET /api/v1/plant-types` | all optional: `query` (fuzzy search), `status` (`UNVERIFIED`\|`VERIFIED`), `inMyCollection`, `limit` (1–200), `offset` |
|
|
294
|
+
| `search_plant_types` | `GET /api/v1/plant-types` | `query` **(required)**, plus the same optional filters/paging as `list_plant_types` |
|
|
295
|
+
| `get_plant_type` | `GET /api/v1/plant-types/:id` | `id` **(required)** — one type's fact sheet |
|
|
296
|
+
| `list_soils` | `GET /api/v1/soils` | none |
|
|
297
|
+
| `list_fertilizers` | `GET /api/v1/fertilizers` | none |
|
|
298
|
+
| `list_pots` | `GET /api/v1/pots` | none |
|
|
299
|
+
|
|
300
|
+
`list_pots` returns the caller's own pots plus the shared default pots (defaults first), each with
|
|
301
|
+
its reference links and recorded sizes.
|
|
302
|
+
|
|
303
|
+
**`list_plant_types` is the shared, community-maintained global catalog** — the same for every
|
|
304
|
+
user, not a per-user list. `list_plant_types` and `search_plant_types` are the same endpoint and
|
|
305
|
+
reader; they exist as two tools because browsing (no `query`) and searching (`query` required) are
|
|
306
|
+
different intents for a model to reach for, even though the underlying call is identical.
|
|
307
|
+
`inMyCollection: true` narrows to types the caller has at least one `LIVING` plant of — each row's
|
|
308
|
+
`viewerPlantsCount` says how many. A `MERGED` or `REJECTED` type never appears in either list.
|
|
309
|
+
|
|
310
|
+
`get_plant_type` returns `{ id, status, mergedIntoId, displayName, botanicalName, names, parent,
|
|
311
|
+
children, facts: { values, sources }, images, viewerPlantsCount, changeLog }`. `facts.values` are
|
|
312
|
+
the effective (inheritance-resolved) catalog values — the same field keys `get_plant`'s `typeFacts`
|
|
313
|
+
uses; `facts.sources` says `"own"` or `"inherited"` per field. `images` are approved URLs only. A
|
|
314
|
+
`MERGED` id **transparently resolves to its survivor** here (unlike the admin
|
|
315
|
+
`admin_get_plant_type`, which returns the redirect stub) — there is nothing for a non-admin caller
|
|
316
|
+
to do with the stub itself. A `REJECTED` id behaves the same way when its outcome was a merge
|
|
317
|
+
(`mergedIntoId` set); otherwise it resolves to the type's own now-`REJECTED` row, since there's no
|
|
318
|
+
survivor to point to.
|
|
319
|
+
|
|
320
|
+
A species missing from this catalog isn't just a read gap: `create_plant_type` (in **Write
|
|
321
|
+
tools** → **Catalogs** below) lets a write-scoped key add it.
|
|
233
322
|
|
|
234
323
|
---
|
|
235
324
|
|
|
236
325
|
## Write tools
|
|
237
326
|
|
|
238
|
-
**All
|
|
239
|
-
with `WRITE:` so a model cannot mistake one for a read.
|
|
327
|
+
**All 31 ordinary write tools require a `write`-scoped key**; a read-only key gets HTTP 403. Every
|
|
328
|
+
description starts with `WRITE:` so a model cannot mistake one for a read.
|
|
240
329
|
|
|
241
330
|
### Care logging
|
|
242
331
|
|
|
@@ -244,6 +333,10 @@ with `WRITE:` so a model cannot mistake one for a read.
|
|
|
244
333
|
|---|---|---|
|
|
245
334
|
| `record_watering` | `POST /api/v1/care/watering` | `plantIds` **(required,** 1–200**)**, `wateredAt` (`YYYY-MM-DD`, defaults to now, no future dates) |
|
|
246
335
|
| `record_fertilization` | `POST /api/v1/care/fertilization` | `plantIds` **(required,** 1–200**)**, `fertilizerId`, `fertilizerPercent` (0–1000), `fertilizedAt` |
|
|
336
|
+
| `record_repotting` | `POST /api/v1/plants/:id/repotting` | `plantId`, `potId` **(both required)**, plus `potSizeId`, `soilId`, `pottedAt`, `hasDrainage`, `hasClimbingAid`, `notes` |
|
|
337
|
+
| `record_refill` | `POST /api/v1/plants/:id/refill` | `plantId` **(required)**, plus `fertilizerId`, `fertilizerPercent`, `refilledAt` |
|
|
338
|
+
| `record_hydro_event` | `POST /api/v1/plants/:id/hydro` | `plantId` **(required)**, plus `kind` (`topup`\|`change`), `waterLevel` (0–100), `fertilizerId`, `fertilizerPercent`, `recordedAt`, `notes` |
|
|
339
|
+
| `snooze_care` | `POST /api/v1/care/snooze` | `plantId`, `careType` (`water`\|`fert`), `durationDays` (1, 3, 7, or 14), `originalDueAt` **(all required)**, `note` |
|
|
247
340
|
|
|
248
341
|
`record_watering` mirrors a one-click watering in the app: reservoir and hydro plants are recorded
|
|
249
342
|
as a refill / top-up automatically, and plants configured to fertilize with watering also get a
|
|
@@ -251,27 +344,57 @@ fertilization logged.
|
|
|
251
344
|
|
|
252
345
|
`record_fertilization` defaults `fertilizerId` and `fertilizerPercent` to **each plant's own
|
|
253
346
|
settings** when omitted, so a bulk call across differently-configured plants still does the right
|
|
254
|
-
thing per plant.
|
|
347
|
+
thing per plant. `record_refill` and `record_hydro_event` default the same two fields the same way.
|
|
348
|
+
|
|
349
|
+
`record_repotting` re-derives the plant's current soil from its latest potting event, and syncs
|
|
350
|
+
watering/fertilizing mode when the new pot's self-watering flag changes it — the same the app's own
|
|
351
|
+
repotting form does. `pottedAt` defaults to today, or to the plant's `acquiredAt` for its very
|
|
352
|
+
first-ever potting event.
|
|
353
|
+
|
|
354
|
+
`record_refill` is the dedicated tool for a reservoir plant's fertilizer/percent — `record_watering`
|
|
355
|
+
already routes a plain watering to a refill for these plants, but without those fields. Like the
|
|
356
|
+
app's own refill form, it also logs a fertilization when the plant feeds with every watering.
|
|
357
|
+
|
|
358
|
+
`record_hydro_event` logs a water top-up (default `kind`) or a full solution change for a
|
|
359
|
+
hydro-cultured plant.
|
|
360
|
+
|
|
361
|
+
`snooze_care` postpones a plant's next watering or fertilizing due date — the same picker the care
|
|
362
|
+
hub offers. `originalDueAt` is the due date being pushed out (`nextDue` from `list_due_care`/
|
|
363
|
+
`get_care_calendar` for that plant/careType), not today's date.
|
|
255
364
|
|
|
256
365
|
### Plant edits
|
|
257
366
|
|
|
258
367
|
| Tool | Endpoint | Arguments |
|
|
259
368
|
|---|---|---|
|
|
260
|
-
| `update_plant_care` | `PATCH /api/v1/plants/:id/care` | `plantId` **(required)**, plus any of `wateringMode` (`scheduled`\|`reservoir`\|`hydro`), `fertilizingMode` (`scheduled`\|`with_watering`), `
|
|
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` |
|
|
369
|
+
| `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` |
|
|
370
|
+
| `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
371
|
|
|
263
372
|
Both take a `plantId` and edit **only the fields you pass** — an omitted field keeps its current
|
|
264
373
|
value, `null` clears it (unassigns a catalog id, or blanks a text field).
|
|
265
374
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
375
|
+
**`update_plant_care` no longer accepts `sunRequirement`/`waterRequirement`** — light and water need
|
|
376
|
+
are type-only facts now (`get_plant`'s `typeFacts`, or `get_plant_type`). `null` on
|
|
377
|
+
`wateringFrequencySummerDays`/`WinterDays`, `wateringFrequencySummer`/`Winter`, or
|
|
378
|
+
`fertilizingCycleSummerWeeks`/`WinterWeeks` now means **"follow the type"** — the plant's effective
|
|
379
|
+
schedule falls back to its type's suggested value (see `get_plant`'s `typeValue` on each schedule
|
|
380
|
+
field) rather than having no schedule at all; `null` on `wateringNotes`/`fertilizingNotes` still just
|
|
381
|
+
clears the note.
|
|
382
|
+
|
|
383
|
+
The watering fields only take effect while the plant's watering is on a schedule — a reservoir/hydro
|
|
384
|
+
plant has no cycle for them to configure, so they're silently ignored for one. The fertilizing-cycle
|
|
385
|
+
fields are likewise ignored while the plant fertilizes with every watering rather than on its own
|
|
386
|
+
cycle. Passing `wateringMode`/`fertilizingMode` in the **same call** as the fields they gate checks
|
|
387
|
+
against the *new* mode, not the current one — so switching a plant to `"reservoir"` while also
|
|
388
|
+
setting a watering cycle just drops that cycle rather than saving it. `careMode` (a legacy mirror of
|
|
389
|
+
`wateringMode`) updates itself; it isn't a field you set. This is also the tool that actually fixes a
|
|
390
|
+
`wateringCycleMismatch` recommendation — that recommendation's own text says "edit the plant," and
|
|
391
|
+
the mode causing the mismatch lives here.
|
|
392
|
+
|
|
393
|
+
`update_plant`'s `plantTypeId` points the plant at a **global** catalog type (from
|
|
394
|
+
`list_plant_types`/`search_plant_types`; a `MERGED` id transparently resolves to its survivor); `null`
|
|
395
|
+
makes the plant "unidentified". `nickname` is required whenever the plant's state *after* this edit
|
|
396
|
+
has no type — set together with `plantTypeId: null`, or already unidentified and not gaining a type
|
|
397
|
+
in this same call.
|
|
275
398
|
|
|
276
399
|
`update_plant`'s `locationId`/`soilId`/`fertilizerId` must be one of **your own** catalog ids (from
|
|
277
400
|
`list_locations`/`list_soils`/`list_fertilizers`) — an unowned or unknown id is rejected.
|
|
@@ -285,13 +408,18 @@ don't exist yet, `propagate_plant` creates them *and* sets the link in one call.
|
|
|
285
408
|
|
|
286
409
|
| Tool | Endpoint | Arguments |
|
|
287
410
|
|---|---|---|
|
|
288
|
-
| `create_plant` | `POST /api/v1/plants` | `plantTypeId`
|
|
411
|
+
| `create_plant` | `POST /api/v1/plants` | `plantTypeId` (optional — omit/`null` for unidentified), `nickname` (required when unidentified), `reference` (default `false`), `parentPlantId` |
|
|
289
412
|
| `propagate_plant` | `POST /api/v1/plants/:id/propagations` | `plantId` **(required)**, `count` (1–10, default 1) |
|
|
290
413
|
| `merge_plants` | `POST /api/v1/plants/merge` | `sourcePlantIds` **(required,** 1–200**)**, plus **exactly one of** `survivorPlantId` or `createNewFrom` |
|
|
291
414
|
| `unmerge_plant` | `POST /api/v1/plants/:id/unmerge` | `plantId` **(required)** |
|
|
415
|
+
| `set_plant_status` | `PATCH /api/v1/plants/:id/status` | `plantId`, `status` (`LIVING`\|`DEAD`\|`LOST`) **(both required)**, plus `deadDeclaredAt`/`deathCause` (only apply when `status` is `DEAD`) |
|
|
416
|
+
| `set_plant_reference` | `PATCH /api/v1/plants/:id/reference` | `plantId`, `reference` (boolean) **(both required)** |
|
|
292
417
|
|
|
293
|
-
`create_plant`
|
|
294
|
-
|
|
418
|
+
`create_plant`'s `plantTypeId` is now **optional**: pass an existing global type id (from
|
|
419
|
+
`list_plant_types`/`search_plant_types`) when the species is known, or omit it (or pass `null`) to
|
|
420
|
+
add an "unidentified" plant — `nickname` is then required, matching the app's plant form. Creating a
|
|
421
|
+
plant *type* is still a UI action, so if the species isn't in the catalog yet, that step happens in
|
|
422
|
+
the app; an unidentified plant can always be identified there later.
|
|
295
423
|
|
|
296
424
|
`reference: true` creates an `EXTERNAL` plant: somebody else's, recorded only so lineage has a real
|
|
297
425
|
node to point at. It gets no acquisition date, no care schedule, and no place in collection stats —
|
|
@@ -307,6 +435,21 @@ call. All sources must be `LIVING`, and `survivorPlantId` must not also appear i
|
|
|
307
435
|
Unmerging leaves the survivor in place. If the merge created it via `createNewFrom` and you no
|
|
308
436
|
longer want it, deleting that plant is a UI action.
|
|
309
437
|
|
|
438
|
+
`set_plant_status` is deliberately narrower than the app's own edit-form status dropdown:
|
|
439
|
+
`GIFTED`/`SOLD`/`TRADED` need a recipient (out of scope — see **What this server deliberately cannot
|
|
440
|
+
do**), and `MERGED`/`SPLIT`/`EXTERNAL` each have their own dedicated tool
|
|
441
|
+
(`merge_plants`/`unmerge_plant`, `set_plant_reference`) that keeps their companion state consistent —
|
|
442
|
+
setting `status` alone to one of those would corrupt it. `deadDeclaredAt` defaults to the plant's
|
|
443
|
+
existing value, then today; moving a plant away from `DEAD` clears both `deadDeclaredAt` and
|
|
444
|
+
`deathCause`.
|
|
445
|
+
|
|
446
|
+
`set_plant_reference` flips a plant between `LIVING` (yours) and `EXTERNAL` (a reference plant —
|
|
447
|
+
somebody else's, kept only as a lineage anchor): `reference: true` requires the plant to currently be
|
|
448
|
+
`LIVING`, `false` requires `EXTERNAL`. Reversible either direction; nothing on the plant is lost.
|
|
449
|
+
Marking a plant as a reference also removes it from any vacation plan, since a reference plant is
|
|
450
|
+
never cared for. `create_plant`'s `reference: true` covers making a *new* reference plant; this is
|
|
451
|
+
for converting one already in the collection.
|
|
452
|
+
|
|
310
453
|
### Health entries
|
|
311
454
|
|
|
312
455
|
| Tool | Endpoint | Arguments |
|
|
@@ -333,6 +476,63 @@ Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `noFerti
|
|
|
333
476
|
`recentlyRepottedAvoidFertilizer`, `noRecentPhoto`. `wateringCycleMismatch` is **not** dismissible —
|
|
334
477
|
it is a configuration contradiction, fixed by editing the plant rather than hidden.
|
|
335
478
|
|
|
479
|
+
### Wishlist
|
|
480
|
+
|
|
481
|
+
| Tool | Endpoint | Arguments |
|
|
482
|
+
|---|---|---|
|
|
483
|
+
| `add_to_wishlist` | `POST /api/v1/wishlist` | `plantTypeId` or `name` — at least one required |
|
|
484
|
+
| `add_wishlist_note` | `POST /api/v1/wishlist/:id/notes` | `wishId` **(required)**, `text` **(required,** 1–500 chars**)** |
|
|
485
|
+
| `remove_from_wishlist` | `DELETE /api/v1/wishlist/:id` | `wishId` **(required)** |
|
|
486
|
+
|
|
487
|
+
`add_to_wishlist` never creates a second wish for a type the caller already has — it returns the
|
|
488
|
+
existing one instead (`duplicate: true`), rather than erroring or silently doubling it up.
|
|
489
|
+
|
|
490
|
+
### Watched types
|
|
491
|
+
|
|
492
|
+
| Tool | Endpoint | Arguments |
|
|
493
|
+
|---|---|---|
|
|
494
|
+
| `watch_type` | `POST /api/v1/plant-type-watches` | `plantTypeId` **(required)** |
|
|
495
|
+
| `unwatch_type` | `DELETE /api/v1/plant-type-watches/:plantTypeId` | `plantTypeId` **(required)** |
|
|
496
|
+
|
|
497
|
+
`watch_type` never creates a second watch for a type the caller already watches — it returns the
|
|
498
|
+
existing one instead (`duplicate: true`), the same convention as `add_to_wishlist`. Unlike the
|
|
499
|
+
in-app eye toggle (which flips whatever the current state is), these are explicit add/remove calls —
|
|
500
|
+
a tool call should never guess the caller's current state.
|
|
501
|
+
|
|
502
|
+
### Notifications
|
|
503
|
+
|
|
504
|
+
| Tool | Endpoint | Arguments |
|
|
505
|
+
|---|---|---|
|
|
506
|
+
| `mark_notifications_read` | `POST /api/v1/notifications/mark-read` | none |
|
|
507
|
+
|
|
508
|
+
Marks every currently-unread notification read — the same as the notification centre's "Alles als
|
|
509
|
+
gelesen markieren" action. There is no per-notification mark-read tool; the app has no such action to
|
|
510
|
+
mirror (opening the page already marks everything read).
|
|
511
|
+
|
|
512
|
+
### Catalogs
|
|
513
|
+
|
|
514
|
+
| Tool | Endpoint | Arguments |
|
|
515
|
+
|---|---|---|
|
|
516
|
+
| `create_location` | `POST /api/v1/locations` | `name` **(required)**, `parentId` (an owned location, to nest under) |
|
|
517
|
+
| `create_soil` | `POST /api/v1/soils` | `name` **(required)**, `notes`, `items` (`{ componentName, parts }[]`, each component upserted by name) |
|
|
518
|
+
| `create_fertilizer` | `POST /api/v1/fertilizers` | `name` **(required)**, `npk`, `baseDosePerLiterMl` (0–100) |
|
|
519
|
+
| `create_pot` | `POST /api/v1/pots` | `name` **(required)**, `material`, `isSelfWatering`, `notes`, `links` (`{ url, label? }[]`) |
|
|
520
|
+
| `create_plant_type` | `POST /api/v1/plant-types` | at least one of `genus`/`commonName`; `species`, `cultivar`, `values` (`sunRequirement`/`waterRequirement` required within it), `note`, `confirmDespiteDuplicates` |
|
|
521
|
+
|
|
522
|
+
`create_location`/`create_soil`/`create_fertilizer`/`create_pot` each mirror the app's own "create
|
|
523
|
+
inline" picker on the plant form — same validation, same duplicate-name handling (a name already
|
|
524
|
+
used by one of your own rows in that catalog is rejected). Pot *sizes* have no create tool yet; add
|
|
525
|
+
one from the app after creating the pot.
|
|
526
|
+
|
|
527
|
+
`create_plant_type` adds a species/cultivar the shared catalog is missing — the same capability as
|
|
528
|
+
the plant-type picker's "Neue Art anlegen" dialog, always landing `UNVERIFIED`. Unlike that dialog
|
|
529
|
+
(which warns about likely duplicates client-side before you even submit), this checks
|
|
530
|
+
**server-side**: if a likely duplicate is found, nothing is created and the response is
|
|
531
|
+
`{ created: false, duplicates }` instead of an error — review the candidates (or resolve the plant
|
|
532
|
+
onto an existing type with `update_plant`/`create_plant`) and either use one of those ids, or call
|
|
533
|
+
again with `confirmDespiteDuplicates: true` to create anyway. Only the dialog's "Steckbrief" subset
|
|
534
|
+
of catalog fields is settable via `values`; a fuller edit needs the UI or an admin key.
|
|
535
|
+
|
|
336
536
|
### Deleting events
|
|
337
537
|
|
|
338
538
|
| Tool | Endpoint | Arguments |
|
|
@@ -340,7 +540,9 @@ it is a configuration contradiction, fixed by editing the plant rather than hidd
|
|
|
340
540
|
| `delete_watering_event` | `DELETE /api/v1/plants/:id/watering-events/:eventId` | `plantId`, `eventId` — both **required** |
|
|
341
541
|
| `delete_fertilization_event` | `DELETE /api/v1/plants/:id/fertilization-events/:eventId` | `plantId`, `eventId` — both **required** |
|
|
342
542
|
|
|
343
|
-
**These are the only
|
|
543
|
+
**These are the only tools that delete collection history, and neither can be undone.** (`remove_from_wishlist`
|
|
544
|
+
also deletes a row, but a wish carries no history — see **What this server deliberately cannot do**
|
|
545
|
+
above.) Take `eventId`
|
|
344
546
|
from `list_watering_events` / `list_fertilization_events` / `list_care_events`. For a reservoir or
|
|
345
547
|
hydro plant, `delete_watering_event` deletes its refill/top-up event instead — the same routing
|
|
346
548
|
`record_watering` uses on the write side.
|
|
@@ -362,22 +564,23 @@ the response, not just the HTTP status.
|
|
|
362
564
|
|
|
363
565
|
## Admin tools (admin-scoped keys only)
|
|
364
566
|
|
|
365
|
-
**
|
|
366
|
-
species/cultivar entry, one catalog for the whole app rather than per-user
|
|
367
|
-
scope (see **Permissions & scopes** above),
|
|
368
|
-
server calls `whoami` once at startup and only
|
|
369
|
-
includes `admin`.
|
|
567
|
+
**18 tools** — 17 over the shared, community-maintained global plant type catalog (every plant's
|
|
568
|
+
species/cultivar entry, one catalog for the whole app rather than per-user) plus one over the
|
|
569
|
+
instance's own operational status. They need the `admin` scope (see **Permissions & scopes** above),
|
|
570
|
+
so a non-admin key's session never even sees them: the server calls `whoami` once at startup and only
|
|
571
|
+
registers these tools when the response's `scopes` includes `admin`.
|
|
370
572
|
|
|
371
|
-
### Read-only (
|
|
573
|
+
### Read-only (7)
|
|
372
574
|
|
|
373
575
|
| Tool | Endpoint | Arguments |
|
|
374
576
|
|---|---|---|
|
|
375
|
-
| `admin_list_plant_types` | `GET /api/v1/admin/plant-types` | all optional: `status` (`UNVERIFIED`\|`VERIFIED`\|`MERGED`, defaults to every
|
|
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 |
|
|
577
|
+
| `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` |
|
|
578
|
+
| `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
579
|
| `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
580
|
| `admin_list_duplicate_candidates` | `GET /api/v1/admin/plant-types/duplicates` | `status` (`OPEN`\|`DISMISSED`\|`MERGED`, defaults to `OPEN`), `limit` (1–200), `offset` |
|
|
379
581
|
| `admin_list_plant_type_proposals` | `GET /api/v1/admin/plant-types/proposals` | `status` (defaults to `PENDING`), `origin` (`user`\|`migration`), `typeId`, `limit` (1–200), `offset` |
|
|
380
582
|
| `admin_preview_plant_type_merge` | `GET /api/v1/admin/plant-types/merge-preview` | `sourceId`, `targetId` **(both required)** — the per-field carry-over plan and merge guard for both directions |
|
|
583
|
+
| `admin_get_dashboard_status` | `GET /api/v1/admin/dashboard` | none — review-queue counts + oldest-item age, catalog health, weekly activity, and operational facts (counts only, never a secret) |
|
|
381
584
|
|
|
382
585
|
`admin_list_plant_types` returns compact rows only (no full field values) — call `admin_get_plant_type`
|
|
383
586
|
for one type's complete fact sheet. Its `search` is fuzzy, the same matcher the admin catalog list
|
|
@@ -393,6 +596,13 @@ write) can always be checked against real bounds/enum values first.
|
|
|
393
596
|
changed field `stale` when the type's value has moved on since the proposal was filed — the exact
|
|
394
597
|
check a human reviewer's accept button re-runs, so an assistant sees the same warning.
|
|
395
598
|
|
|
599
|
+
`admin_get_dashboard_status` is the one admin tool that isn't about the catalog — it returns the same
|
|
600
|
+
aggregate the app's own `/admin` dashboard shows, for "what needs me, and is anything wrong" in one
|
|
601
|
+
call instead of several. Some values are legitimately absent rather than zero: a review-queue item's
|
|
602
|
+
`age` comes back `{ kind: "unknown" }` when its type has no creation timestamp to measure from
|
|
603
|
+
(pending user registrations), and `operations.appliedMigrationsCount` can be `null` when that guarded
|
|
604
|
+
read failed — never a fabricated number either way.
|
|
605
|
+
|
|
396
606
|
### Write (11)
|
|
397
607
|
|
|
398
608
|
Every description starts with `ADMIN WRITE:`, and **every one of these requires a non-empty `note`**
|
|
@@ -490,6 +700,34 @@ can act on them or explain them to you:
|
|
|
490
700
|
|
|
491
701
|
A non-JSON body is passed through as raw text rather than being swallowed.
|
|
492
702
|
|
|
703
|
+
## Breaking changes in 2.0.0
|
|
704
|
+
|
|
705
|
+
Plant identity moved from a per-user catalog to a shared, community-maintained one. If your client
|
|
706
|
+
(or a cached tool call) held onto a `plantTypeId` from before this release, it needs re-resolving —
|
|
707
|
+
the id spaces are unrelated. The full rationale is in the app's `docs/mcp-server.md`; the mechanical
|
|
708
|
+
changes:
|
|
709
|
+
|
|
710
|
+
- **`plantTypeId` means a global catalog id everywhere it appears** (`list_plants`, `get_plant`,
|
|
711
|
+
`create_plant`, `update_plant`) — not the old per-user type id. Re-resolve any id you cached before
|
|
712
|
+
this release via `list_plant_types`/`search_plant_types`.
|
|
713
|
+
- **`list_plant_types` now serves the global catalog**, not the caller's own types — every caller
|
|
714
|
+
sees the same rows now, differing only in `viewerPlantsCount`.
|
|
715
|
+
- **New: `search_plant_types` and `get_plant_type`** — see **Catalogs** above.
|
|
716
|
+
- **`plantTypeId` is now optional on `create_plant` and `update_plant`** — omit it (or pass `null`)
|
|
717
|
+
for an "unidentified" plant. `nickname` becomes required whenever the plant ends up with no type.
|
|
718
|
+
- **`get_plant`'s `plantType` field is replaced by `type`** — see its row above for the new shape
|
|
719
|
+
(`plantTypeId`, `nickname`, `displayName`, `botanicalName`, `typeStatus`, `isUnidentified`).
|
|
720
|
+
`list_plants` rows changed the same way.
|
|
721
|
+
- **`get_plant` gained `typeFacts`** (the type's effective light/water/feeding need, toxicity,
|
|
722
|
+
climate, growth, substrate/propagation, family/native region).
|
|
723
|
+
- **`get_plant`'s six schedule fields are no longer raw values** — each is now
|
|
724
|
+
`{ value, source, typeValue }`. A consumer reading e.g. `plant.wateringFrequencySummerDays` as a
|
|
725
|
+
number directly will break; read `.value` instead.
|
|
726
|
+
- **`update_plant_care` no longer accepts `sunRequirement`/`waterRequirement`** — both are type-only
|
|
727
|
+
facts now.
|
|
728
|
+
- **`null` on `update_plant_care`'s watering/fertilizing-cycle fields now means "follow the type"**,
|
|
729
|
+
not "no schedule" — see that tool's row above.
|
|
730
|
+
|
|
493
731
|
## Versioning
|
|
494
732
|
|
|
495
733
|
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
|
|
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
|
|
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.
|
|
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:
|
|
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."),
|
|
@@ -152,6 +152,7 @@ server.tool("list_fertilization_events", "List fertilization events for a plant,
|
|
|
152
152
|
server.tool("list_care_events", "List the combined care timeline for a plant (watering, fertilization, refill, hydro, potting, snoozes), each tagged with a kind, most recent first.", eventArgs, async ({ id, ...q }) => apiGet(`/api/v1/plants/${id}/events`, q));
|
|
153
153
|
server.tool("list_photos", "List photo metadata for a plant (urls, takenAt), most recent first.", eventArgs, async ({ id, ...q }) => apiGet(`/api/v1/plants/${id}/photos`, q));
|
|
154
154
|
server.tool("list_health_entries", "List health and AI-analysis entries for a plant, most recent first.", eventArgs, async ({ id, ...q }) => apiGet(`/api/v1/plants/${id}/health`, q));
|
|
155
|
+
server.tool("list_diagnoses", "List past guided problem-diagnosis runs for a plant ('Problem eingrenzen' in the app), most recent first: the symptom, every question with the answer given, the ranked possible causes, the chosen action, and the AI second opinion if one was requested. Resolved to plain English text. Read-only — the guided flow itself is a UI-only experience; use add_health_entry to log an observation directly instead.", eventArgs, async ({ id, ...q }) => apiGet(`/api/v1/plants/${id}/diagnoses`, q));
|
|
155
156
|
// --- Care schedule (calendar) ----------------------------------------------
|
|
156
157
|
/**
|
|
157
158
|
* Derived schedule state, computed server-side from the same helpers the app's
|
|
@@ -206,6 +207,7 @@ server.tool("collection_stats", "Counts and totals across the whole collection:
|
|
|
206
207
|
server.tool("sitter_briefing", "Printable care sheet for the caller's current or next sitter-covered vacation: per-plant sitter instructions, watering notes, watering cycle and next-due state, plus the day-by-day watering schedule for the trip. Returns { hasVacation: false } when there is no upcoming or running vacation in 'sitter' mode. Read-only — does not create or modify a vacation.", {
|
|
207
208
|
locationId: z.number().int().optional().describe("Only include plants in this location (id from list_locations). Omit for every plant on the vacation."),
|
|
208
209
|
}, async (args) => apiGet("/api/v1/vacation/sitter-briefing", args));
|
|
210
|
+
server.tool("get_vacation_status", "Whether the caller has a current or upcoming vacation, in either mode ('sitter' or 'shift') — unlike sitter_briefing, which only ever reports a 'sitter'-mode one. Returns { hasVacation: false } when none is running or scheduled, otherwise the vacation's id, mode, date range, whether it has started yet (isActive/isUpcoming), and how many plants it covers. Read-only status only — for a sitter-mode vacation's full per-plant detail and schedule, use sitter_briefing.", async () => apiGet("/api/v1/vacation/status"));
|
|
209
211
|
// --- Care logging (writes) --------------------------------------------------
|
|
210
212
|
/**
|
|
211
213
|
* Mutating tools. These require an API key with the `write` scope; a read-only
|
|
@@ -234,24 +236,56 @@ server.tool("record_fertilization", "WRITE: log a fertilization for one or more
|
|
|
234
236
|
.describe("Strength as a percentage of the base dose; defaults to each plant's own."),
|
|
235
237
|
fertilizedAt: optionalPlantDate(""),
|
|
236
238
|
}, async (args) => apiSend("POST", "/api/v1/care/fertilization", { ...args }));
|
|
237
|
-
server.tool("
|
|
239
|
+
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.", {
|
|
240
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
241
|
+
potId: z.number().int().positive().describe("Pot id, from list_pots (the caller's own pots, plus shared defaults)."),
|
|
242
|
+
potSizeId: z.number().int().positive().optional().describe("One of the pot's own recorded sizes, from that pot's row in list_pots."),
|
|
243
|
+
soilId: z.number().int().positive().optional().describe("Soil mix id, from list_soils."),
|
|
244
|
+
pottedAt: optionalPlantDate("Defaults to today, or to the plant's acquiredAt for its first-ever potting event."),
|
|
245
|
+
hasDrainage: z.boolean().optional().describe("Whether the pot has a drainage hole."),
|
|
246
|
+
hasClimbingAid: z.boolean().optional().describe("Whether a climbing aid (moss pole, trellis) was added."),
|
|
247
|
+
notes: z.string().max(2000).optional().describe("Free-text notes about this repotting."),
|
|
248
|
+
}, async ({ plantId, ...body }) => apiSend("POST", `/api/v1/plants/${plantId}/repotting`, body));
|
|
249
|
+
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.", {
|
|
250
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
251
|
+
fertilizerId: z.number().int().positive().optional().describe("Override fertilizer; defaults to the plant's own."),
|
|
252
|
+
fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose; defaults to the plant's own."),
|
|
253
|
+
refilledAt: optionalPlantDate(""),
|
|
254
|
+
}, async ({ plantId, ...body }) => apiSend("POST", `/api/v1/plants/${plantId}/refill`, body));
|
|
255
|
+
server.tool("record_hydro_event", "WRITE: log a hydro-culture event for a hydro-mode plant — a water top-up (default) or a full solution change. waterLevel (0-100) and the fertilizer fields are optional for either kind. Requires a write-scoped API key.", {
|
|
256
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
257
|
+
kind: z.enum(["topup", "change"]).optional().describe("'topup' (default) or 'change' (full solution change)."),
|
|
258
|
+
waterLevel: z.number().int().min(0).max(100).optional().describe("Reservoir fill level as a percentage."),
|
|
259
|
+
fertilizerId: z.number().int().positive().optional().describe("Override fertilizer; defaults to the plant's own."),
|
|
260
|
+
fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose; defaults to the plant's own."),
|
|
261
|
+
recordedAt: optionalPlantDate(""),
|
|
262
|
+
notes: z.string().max(2000).optional().describe("Free-text notes."),
|
|
263
|
+
}, async ({ plantId, ...body }) => apiSend("POST", `/api/v1/plants/${plantId}/hydro`, body));
|
|
264
|
+
server.tool("snooze_care", "WRITE: postpone a plant's next watering or fertilizing due date by a fixed duration — the same snooze picker the care hub offers. originalDueAt is the due date being pushed out (the nextDue value from list_due_care/get_care_calendar for that plant/careType), not today's date. durationDays must be one of the app's presets: 1, 3, 7, or 14. Requires a write-scoped API key.", {
|
|
265
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
266
|
+
careType: z.enum(["water", "fert"]).describe("Which schedule to postpone."),
|
|
267
|
+
durationDays: z.union([z.literal(1), z.literal(3), z.literal(7), z.literal(14)]).describe("Snooze duration in days — one of the app's presets."),
|
|
268
|
+
originalDueAt: z.string().describe("The due date being pushed out, e.g. from list_due_care's nextDue for this plant/careType."),
|
|
269
|
+
note: z.string().max(160).optional().describe("Free-text reason, e.g. 'away for the week'."),
|
|
270
|
+
}, async (args) => apiSend("POST", "/api/v1/care/snooze", { ...args }));
|
|
271
|
+
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
272
|
plantId: z.number().int().positive().describe("Plant id."),
|
|
239
273
|
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
274
|
fertilizingMode: z.enum(["scheduled", "with_watering"]).optional().describe("How this plant is fertilized: on its own cycle, or automatically with every watering."),
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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."),
|
|
275
|
+
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."),
|
|
276
|
+
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."),
|
|
277
|
+
wateringFrequencyWinter: z.string().max(50).nullable().optional().describe("Free-text winter watering label. null follows the type's guidance. Ignored unless watering is scheduled."),
|
|
278
|
+
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
279
|
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'."),
|
|
280
|
+
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'."),
|
|
281
|
+
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
282
|
fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose."),
|
|
251
283
|
fertilizingNotes: z.string().max(500).nullable().optional().describe("Free-text fertilizing notes."),
|
|
252
284
|
}, 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.", {
|
|
285
|
+
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
286
|
plantId: z.number().int().positive().describe("Plant id."),
|
|
287
|
+
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."),
|
|
288
|
+
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
289
|
locationId: z.number().int().positive().nullable().optional().describe("New location id, from list_locations. null unassigns it."),
|
|
256
290
|
soilId: z.number().int().positive().nullable().optional().describe("New soil id, from list_soils. null unassigns it."),
|
|
257
291
|
fertilizerId: z.number().int().positive().nullable().optional().describe("New fertilizer id, from list_fertilizers. null unassigns it."),
|
|
@@ -308,8 +342,9 @@ server.tool("delete_fertilization_event", "WRITE: delete a fertilization event f
|
|
|
308
342
|
eventId: z.number().int().positive().describe("Event id, from list_fertilization_events or list_care_events."),
|
|
309
343
|
}, async ({ plantId, eventId }) => apiSend("DELETE", `/api/v1/plants/${plantId}/fertilization-events/${eventId}`));
|
|
310
344
|
// --- Plant lifecycle (writes) ------------------------------------------------
|
|
311
|
-
server.tool("create_plant", "WRITE: add a new plant to the collection. plantTypeId is
|
|
312
|
-
plantTypeId: z.number().int().positive().describe("
|
|
345
|
+
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.", {
|
|
346
|
+
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."),
|
|
347
|
+
nickname: z.string().max(100).optional().describe("The plant's own name. Required when plantTypeId is omitted or null."),
|
|
313
348
|
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
349
|
parentPlantId: z.number().int().positive().optional().describe("Optional: the plant this one was propagated from."),
|
|
315
350
|
}, async (body) => apiSend("POST", "/api/v1/plants", { ...body }));
|
|
@@ -325,11 +360,135 @@ server.tool("merge_plants", "WRITE: merge several living plants into one — for
|
|
|
325
360
|
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
361
|
plantId: z.number().int().positive().describe("The MERGED plant to restore."),
|
|
327
362
|
}, async ({ plantId }) => apiSend("POST", `/api/v1/plants/${plantId}/unmerge`));
|
|
363
|
+
server.tool("set_plant_status", "WRITE: set a plant's lifecycle status to LIVING, DEAD, or LOST. This is deliberately narrower than the full status range the app's edit form offers: GIFTED/SOLD/TRADED need a recipient and are out of scope for an API key (see docs), and MERGED/SPLIT/EXTERNAL each have their own dedicated tools (merge_plants/unmerge_plant, set_plant_reference) that keep their companion state consistent — setting status alone to one of those would corrupt it. deadDeclaredAt/deathCause only apply when status is DEAD (deadDeclaredAt defaults to the plant's existing value, then today); moving away from DEAD clears both. Requires a write-scoped API key.", {
|
|
364
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
365
|
+
status: z.enum(["LIVING", "DEAD", "LOST"]).describe("New status."),
|
|
366
|
+
deadDeclaredAt: optionalPlantDate("Only applies when status is DEAD."),
|
|
367
|
+
deathCause: z
|
|
368
|
+
.enum(["overwatering", "underwatering", "pests", "disease", "rootRot", "light", "cold", "transplantShock", "oldAge", "unknown", "other"])
|
|
369
|
+
.optional()
|
|
370
|
+
.describe("Only applies when status is DEAD."),
|
|
371
|
+
}, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}/status`, body));
|
|
372
|
+
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.", {
|
|
373
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
374
|
+
reference: z.boolean().describe("true: mark as a reference plant (must be LIVING now). false: turn a reference plant back into the caller's own (must be EXTERNAL now)."),
|
|
375
|
+
}, async ({ plantId, reference }) => apiSend("PATCH", `/api/v1/plants/${plantId}/reference`, { reference }));
|
|
376
|
+
// --- Wishlist ----------------------------------------------------------
|
|
377
|
+
/**
|
|
378
|
+
* The caller's personal wishlist (R2-15) — plants they want but don't own
|
|
379
|
+
* yet, either a global catalog type (`plantTypeId`) or a free-text name
|
|
380
|
+
* fallback for a species not in the catalog. Unlike sales/trades, wishes
|
|
381
|
+
* are never exposed-but-excluded: they're the caller's own data, about
|
|
382
|
+
* their own collection, so there's no second-person consent concern.
|
|
383
|
+
*/
|
|
384
|
+
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"));
|
|
385
|
+
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.", {
|
|
386
|
+
plantTypeId: z.number().int().positive().optional().describe("An existing global plant type id."),
|
|
387
|
+
name: z.string().max(100).optional().describe("Free-text name fallback, for a species not yet in the catalog."),
|
|
388
|
+
}, async ({ plantTypeId, name }) => apiSend("POST", "/api/v1/wishlist", { plantTypeId, name }));
|
|
389
|
+
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.", {
|
|
390
|
+
wishId: z.number().int().positive().describe("Wish id, from list_wishlist."),
|
|
391
|
+
text: z.string().min(1).max(500).describe("The note text."),
|
|
392
|
+
}, async ({ wishId, text }) => apiSend("POST", `/api/v1/wishlist/${wishId}/notes`, { text }));
|
|
393
|
+
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.", {
|
|
394
|
+
wishId: z.number().int().positive().describe("Wish id, from list_wishlist."),
|
|
395
|
+
}, async ({ wishId }) => apiSend("DELETE", `/api/v1/wishlist/${wishId}`));
|
|
396
|
+
// --- Watched plant types -------------------------------------------------
|
|
397
|
+
/**
|
|
398
|
+
* The caller's "watch a type" subscriptions — the eye toggle on a catalog
|
|
399
|
+
* row or type page. A watched type notifies its watcher (in-app, via the
|
|
400
|
+
* Neuigkeiten centre — see list_notifications below) whenever an admin
|
|
401
|
+
* edits its fields, identity or names, verifies it, merges it into another
|
|
402
|
+
* type, or a new photo is approved for it.
|
|
403
|
+
*/
|
|
404
|
+
server.tool("list_watched_types", "List the caller's watched plant types: each watch's type id, display/botanical name and status.", async () => apiGet("/api/v1/plant-type-watches"));
|
|
405
|
+
server.tool("watch_type", "WRITE: watch a global catalog type (plantTypeId, from list_plant_types/search results) — the caller gets notified in-app when its fields, identity, names, verification status or photos change. Never creates a second watch for a type already watched — returns the existing one instead (duplicate: true). Requires a write-scoped API key.", {
|
|
406
|
+
plantTypeId: z.number().int().positive().describe("An existing global plant type id."),
|
|
407
|
+
}, async ({ plantTypeId }) => apiSend("POST", "/api/v1/plant-type-watches", { plantTypeId }));
|
|
408
|
+
server.tool("unwatch_type", "WRITE: stop watching a plant type. Requires a write-scoped API key.", {
|
|
409
|
+
plantTypeId: z.number().int().positive().describe("The watched type's id, from list_watched_types."),
|
|
410
|
+
}, async ({ plantTypeId }) => apiSend("DELETE", `/api/v1/plant-type-watches/${plantTypeId}`));
|
|
411
|
+
// --- Notifications -----------------------------------------------------------
|
|
412
|
+
/**
|
|
413
|
+
* The in-app notification centre (`/notifications`): catalog decisions on a
|
|
414
|
+
* watched type, a watched/created type's photo or proposal outcome, and a
|
|
415
|
+
* collection share — one durable, always-on channel alongside push/email.
|
|
416
|
+
* Sale/trade notifications are always excluded here, the same permanent
|
|
417
|
+
* exclusion `list_recent_activity` applies — each names a second person who
|
|
418
|
+
* never consented to the owner's API key.
|
|
419
|
+
*/
|
|
420
|
+
server.tool("list_notifications", "List the caller's notifications, newest first, cursor-paginated. Each item has a messageKey and values (interpolation data, not pre-rendered text — the exact sentence depends on locale) plus an href into the app and, when the event has one, the actor who triggered it. Sale/trade notifications are never included. Pass the previous call's nextCursor to load the next page.", {
|
|
421
|
+
cursor: z.number().int().optional().describe("Pass the previous call's nextCursor to continue; omit for the first page."),
|
|
422
|
+
}, async ({ cursor }) => apiGet("/api/v1/notifications", { cursor }));
|
|
423
|
+
server.tool("mark_notifications_read", "WRITE: mark every currently-unread notification read — the same as the notification centre's 'Alles als gelesen markieren' action. There is no per-notification mark-read tool; the app itself has none to mirror (opening the page already marks everything read). Requires a write-scoped API key.", async () => apiSend("POST", "/api/v1/notifications/mark-read"));
|
|
328
424
|
// --- Catalogs (for resolving filter ids) -----------------------------------
|
|
329
425
|
server.tool("list_locations", "List the user's locations.", async () => apiGet("/api/v1/locations"));
|
|
330
|
-
|
|
426
|
+
/**
|
|
427
|
+
* R2-12: the global plant type catalog, shared by every user — not a
|
|
428
|
+
* per-user list any more. `list_plant_types` and `search_plant_types` are
|
|
429
|
+
* deliberately the same underlying endpoint/reader (`GET /api/v1/plant-types`
|
|
430
|
+
* — see `docs/mcp-server.md`'s "Catalog reads" section): browsing and
|
|
431
|
+
* searching are the same fuzzy-filtered, paged list, just with `query`
|
|
432
|
+
* required for one and optional for the other, so a model reaching for
|
|
433
|
+
* either gets the same result shape and paging behavior.
|
|
434
|
+
*/
|
|
435
|
+
const publicPlantTypeListArgs = {
|
|
436
|
+
status: z.enum(["UNVERIFIED", "VERIFIED"]).optional().describe("Filter by review status. Defaults to every live type (both)."),
|
|
437
|
+
inMyCollection: z.boolean().optional().describe("true: only types the caller has at least one LIVING plant of."),
|
|
438
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
|
|
439
|
+
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
440
|
+
};
|
|
441
|
+
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() }));
|
|
442
|
+
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() }));
|
|
443
|
+
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}`));
|
|
444
|
+
server.tool("create_plant_type", "WRITE: add a species/cultivar the shared global catalog is missing — the same capability as the plant-type picker's 'Neue Art anlegen' dialog. Always lands UNVERIFIED. At least one of genus or commonName is required. Only a small 'Steckbrief' subset of catalog fields is settable here (sunRequirement and waterRequirement are required within it; suggestedWateringDaysSummer/Winter, toxicToPets, difficulty are optional) — a fuller edit needs the UI or an admin key. Unlike the UI dialog (which warns about likely duplicates client-side before you even submit), this checks server-side: if a likely duplicate is found, nothing is created and the response is { created: false, duplicates } instead — review them (or resolve the plant onto an existing type with update_plant/create_plant) and either use one of those ids, or call again with confirmDespiteDuplicates: true to create anyway. Requires a write-scoped API key.", {
|
|
445
|
+
genus: z.string().max(100).optional().describe("Genus. Required together with species/cultivar identity, or provide commonName instead."),
|
|
446
|
+
species: z.string().max(100).optional().describe("Species epithet."),
|
|
447
|
+
cultivar: z.string().max(100).optional().describe("Cultivar name."),
|
|
448
|
+
commonName: z.string().max(100).optional().describe("A common name, in the caller's own locale. Required if genus is omitted."),
|
|
449
|
+
values: z
|
|
450
|
+
.object({
|
|
451
|
+
sunRequirement: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
452
|
+
waterRequirement: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
453
|
+
suggestedWateringDaysSummer: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
454
|
+
suggestedWateringDaysWinter: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
455
|
+
toxicToPets: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
456
|
+
difficulty: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
457
|
+
})
|
|
458
|
+
.describe("The 'Steckbrief' fields — sunRequirement and waterRequirement are required (use admin_get_plant_type_field_schema's field definitions for exact value shapes/bounds); the rest are optional."),
|
|
459
|
+
note: z.string().max(500).optional().describe("Optional context for the catalog change log. Defaults to a generic 'created via MCP' note."),
|
|
460
|
+
confirmDespiteDuplicates: z.boolean().optional().describe("Set true to create anyway after reviewing the duplicates a prior call returned."),
|
|
461
|
+
}, async (body) => apiSend("POST", "/api/v1/plant-types", body));
|
|
462
|
+
server.tool("create_location", "WRITE: create a location — the same 'create inline' picker on the plant form. Never conflicts with an existing owned catalog entry: a duplicate name is rejected by the underlying unique constraint, surfaced as an error. Requires a write-scoped API key.", {
|
|
463
|
+
name: z.string().min(1).max(100).describe("Location name."),
|
|
464
|
+
parentId: z.number().int().positive().optional().describe("An owned location id to nest this one under (from list_locations). Omit for a top-level location."),
|
|
465
|
+
}, async ({ name, parentId }) => apiSend("POST", "/api/v1/locations", { name, parentId }));
|
|
331
466
|
server.tool("list_soils", "List the user's soils.", async () => apiGet("/api/v1/soils"));
|
|
467
|
+
server.tool("create_soil", "WRITE: create a soil mix, optionally with components (name + parts) — mirrors the app's soil mix editor. Each component is matched or created by name under the caller's account. Requires a write-scoped API key.", {
|
|
468
|
+
name: z.string().min(1).max(100).describe("Soil name."),
|
|
469
|
+
notes: z.string().max(2000).optional().describe("Free-text notes."),
|
|
470
|
+
items: z
|
|
471
|
+
.array(z.object({ componentName: z.string().min(1).max(100), parts: z.number().int().min(1).max(100) }))
|
|
472
|
+
.optional()
|
|
473
|
+
.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."),
|
|
474
|
+
}, async ({ name, notes, items }) => apiSend("POST", "/api/v1/soils", { name, notes, items }));
|
|
332
475
|
server.tool("list_fertilizers", "List the user's fertilizers.", async () => apiGet("/api/v1/fertilizers"));
|
|
476
|
+
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.", {
|
|
477
|
+
name: z.string().min(1).max(100).describe("Fertilizer name."),
|
|
478
|
+
npk: z.string().max(30).optional().describe("NPK ratio label, e.g. '7-3-6'."),
|
|
479
|
+
baseDosePerLiterMl: z.number().min(0).max(100).optional().describe("Base dose in ml per liter of water."),
|
|
480
|
+
}, async ({ name, npk, baseDosePerLiterMl }) => apiSend("POST", "/api/v1/fertilizers", { name, npk, baseDosePerLiterMl }));
|
|
481
|
+
server.tool("list_pots", "List the caller's own pots plus the shared default pots, defaults first. Each includes its reference links and any recorded sizes (addressed by id from record_repotting-style potSize fields once that tool exists).", async () => apiGet("/api/v1/pots"));
|
|
482
|
+
server.tool("create_pot", "WRITE: create a pot, optionally with reference links (a shop or care-guide URL). Pot sizes have no create tool yet — add one from the app after creating the pot. Requires a write-scoped API key.", {
|
|
483
|
+
name: z.string().min(1).max(100).describe("Pot name."),
|
|
484
|
+
material: z.string().max(50).optional().describe("Material, e.g. 'Terracotta'."),
|
|
485
|
+
isSelfWatering: z.boolean().optional().describe("Whether the pot is self-watering."),
|
|
486
|
+
notes: z.string().max(2000).optional().describe("Free-text notes."),
|
|
487
|
+
links: z
|
|
488
|
+
.array(z.object({ url: z.string().max(2000), label: z.string().max(80).optional() }))
|
|
489
|
+
.optional()
|
|
490
|
+
.describe("Reference links, e.g. a shop listing. Order follows the array."),
|
|
491
|
+
}, async ({ name, material, isSelfWatering, notes, links }) => apiSend("POST", "/api/v1/pots", { name, material, isSelfWatering, notes, links }));
|
|
333
492
|
// --- Admin: global plant type catalog (admin-scoped keys only) -------------
|
|
334
493
|
/**
|
|
335
494
|
* Tools over the shared, community-maintained plant type catalog — finding
|
|
@@ -454,6 +613,7 @@ function registerAdminTools() {
|
|
|
454
613
|
note: z.string().min(1).describe("Required, but not persisted — see the tool description."),
|
|
455
614
|
}, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/duplicates/${id}/dismiss`, { note }));
|
|
456
615
|
server.tool("admin_rescan_duplicate_candidates", "ADMIN WRITE: re-run duplicate detection against the whole live catalog and reconcile the duplicate-candidate queue — the same 'Rescan' button the admin Duplicates tab has, for when catalog edits (new types, merges, name or identity changes) since the last migration run mean the queue no longer reflects what a fresh scan would produce. A previously dismissed or already-merged pair never comes back. No note needed: this only rebuilds the queue, there is no reviewable decision to leave a reason for. Returns how many candidates were added, updated, and removed. Requires an admin-scoped API key.", async () => apiSend("POST", "/api/v1/admin/plant-types/duplicates/rescan", {}));
|
|
616
|
+
server.tool("admin_get_dashboard_status", "ADMIN: the instance's operational status in one call — the same aggregate the app's own /admin dashboard shows. Four groups: reviewQueue (pending proposals, pending submitted images, open duplicate candidates, unverified types, and pending user registrations — each with its count and the age in days of its oldest item), catalogHealth (verified/unverified split, types with no care values at all, orphan cultivars, types with no plants, low-completeness types, and plants still unidentified), activity (new plants, care events logged, and contributions submitted/decided, per trailing week over the last several weeks), and operations (push-enabled users, pending email verifications, active API keys, and the applied migration count — counts only, never a secret, token, key or hash). No arguments; always reads the whole instance. Some values are legitimately absent rather than zero: a queue's age comes back as 'unknown' when its item type has no creation timestamp to measure from (pending user registrations have no such column), and the applied-migration count can be null when that guarded read failed — neither is a fabricated number. Read-only; nothing here writes. Requires an admin-scoped API key.", async () => apiGet("/api/v1/admin/dashboard"));
|
|
457
617
|
}
|
|
458
618
|
/**
|
|
459
619
|
* Calls `/api/v1/me` once at startup to decide whether this key carries the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sebamomann/plants-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "MCP server for the Sprig plant app:
|
|
3
|
+
"version": "2.5.0",
|
|
4
|
+
"description": "MCP server for the Sprig plant app: 76 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, 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 for an admin-scoped key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "sebamomann <github@sebamomann.de>",
|