@sebamomann/plants-mcp 1.8.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +127 -0
- package/README.md +270 -41
- package/dist/index.js +224 -18
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,133 @@
|
|
|
3
3
|
Notable changes to `@sebamomann/plants-mcp`. Versioning is semver against the **tool surface** —
|
|
4
4
|
see the table in `AGENTS.md` for what counts as major, minor, and patch.
|
|
5
5
|
|
|
6
|
+
## 2.0.0 — 2026-09-15
|
|
7
|
+
|
|
8
|
+
**Plant identity moves to a shared, community-maintained global catalog.** 56 tools registered
|
|
9
|
+
overall (22 read, 17 write, 17 admin). This is a **major** bump — see `docs/mcp-server.md`'s
|
|
10
|
+
"Breaking changes in 2.0.0" section for the full list; summary:
|
|
11
|
+
|
|
12
|
+
- **`plantTypeId` is now a global catalog id everywhere it appears** (`list_plants`, `get_plant`,
|
|
13
|
+
`create_plant`, `update_plant`) — a legacy per-user type id cached before this release does not
|
|
14
|
+
refer to the same row. Re-resolve any cached id via `list_plant_types`/`search_plant_types`.
|
|
15
|
+
- **`list_plant_types` now serves the shared global catalog**, not a per-user list.
|
|
16
|
+
- **New tools:**
|
|
17
|
+
- `search_plant_types` — fuzzy search the global catalog by name (any locale) or botanical name.
|
|
18
|
+
Same underlying endpoint as `list_plant_types`; `query` is required here, optional there.
|
|
19
|
+
- `get_plant_type` — one type's fact sheet: identity, names by locale, parent/cultivar children,
|
|
20
|
+
effective catalog facts with each field's source (`"own"`/`"inherited"`), approved image URLs,
|
|
21
|
+
and the caller's own plant count. A `MERGED` id transparently resolves to its survivor.
|
|
22
|
+
- **`create_plant`'s `plantTypeId` is now optional** — omit it (or pass `null`) to add an
|
|
23
|
+
"unidentified" plant; `nickname` is then required, matching the app's plant form.
|
|
24
|
+
- **`update_plant` gained `plantTypeId` and `nickname`** — re-identify a plant, or make it
|
|
25
|
+
unidentified (`plantTypeId: null` + a `nickname`).
|
|
26
|
+
- **`get_plant`'s `plantType` field is replaced by `type`** —
|
|
27
|
+
`{ plantTypeId, nickname, displayName, botanicalName, typeStatus, isUnidentified }`. `list_plants`
|
|
28
|
+
rows changed the same way.
|
|
29
|
+
- **`get_plant` gained `typeFacts`** — the type's effective light/water/feeding need, toxicity,
|
|
30
|
+
climate, growth, substrate/propagation, and family/native region.
|
|
31
|
+
- **`get_plant`'s six schedule fields are no longer raw values** — each is now
|
|
32
|
+
`{ value, source: "plant"|"type"|"none", typeValue }`.
|
|
33
|
+
- **`update_plant_care` no longer accepts `sunRequirement`/`waterRequirement`** — both are type-only
|
|
34
|
+
facts now, read via `get_plant`'s `typeFacts` or `get_plant_type`.
|
|
35
|
+
- **`null` on `update_plant_care`'s watering/fertilizing-cycle fields now means "follow the type"**
|
|
36
|
+
instead of "no schedule" — the tool's zod schema now accepts `null` for those four fields, which
|
|
37
|
+
it previously rejected. A value equal to the type's value is stored as `null` too, so the plant
|
|
38
|
+
keeps following later changes to the type; `update_plant` switching `plantTypeId` moves values
|
|
39
|
+
that followed the old type onto the new one (or, to no type, writes them in).
|
|
40
|
+
- **`list_plants`'s `sort=commonName` is renamed `sort=displayName`** (resolved in memory — no
|
|
41
|
+
longer a database column) and its `search` now matches the plant's nickname and its type's names
|
|
42
|
+
in every locale, not a legacy `PlantType.commonName` column.
|
|
43
|
+
- **`GlobalPlantTypeStatus` gained a `REJECTED` value** — an admin can reject a user-created
|
|
44
|
+
`UNVERIFIED` type in the app (required note, plus a merge/unlink outcome for its plants); the
|
|
45
|
+
row persists, analogous to `MERGED`. `list_plant_types`/`search_plant_types` never return one;
|
|
46
|
+
`get_plant_type` on a rejected id behaves like `MERGED` (redirects when the outcome was a merge).
|
|
47
|
+
`admin_get_plant_type`'s `status` can now be `"REJECTED"`. No new tool triggers this yet — see
|
|
48
|
+
`docs/mcp-server.md`'s roadmap (`admin_reject_plant_type`).
|
|
49
|
+
|
|
50
|
+
## 1.10.0 — 2026-09-15
|
|
51
|
+
|
|
52
|
+
**Wishlist tools.** Four tools total — 54 registered overall (20 read, 17 write, 17 admin):
|
|
53
|
+
|
|
54
|
+
- `list_wishlist` — the caller's wishlist: each wish's plant type (display/botanical name,
|
|
55
|
+
`typeStatus`) when set, or a free-text `name` fallback, plus dated `notes` and
|
|
56
|
+
`ownedLivingCount` (how many `LIVING` plants of that type the caller already has).
|
|
57
|
+
- `add_to_wishlist` (write) — add a wish by `plantTypeId` or free-text `name`. Never creates a
|
|
58
|
+
second wish for a type the caller already has — returns the existing one instead
|
|
59
|
+
(`duplicate: true`).
|
|
60
|
+
- `add_wishlist_note` (write) — add a dated note to a wish (price, where to get it, a shop link).
|
|
61
|
+
- `remove_from_wishlist` (write) — remove a wish. Not counted as a "destructive" tool in the sense
|
|
62
|
+
`delete_watering_event`/`delete_fertilization_event` are — a wish carries no collection history,
|
|
63
|
+
and is trivially re-added.
|
|
64
|
+
|
|
65
|
+
## 1.9.0 — 2026-09-15
|
|
66
|
+
|
|
67
|
+
**Admin keys can now read, directly edit, merge, and review the shared global plant type catalog.**
|
|
68
|
+
Seventeen tools total for an `admin`-scoped key — 50 registered overall (19 read, 14 write, 17
|
|
69
|
+
admin: 6 read-only, 11 that write) — registered only for a key that carries the `admin` scope (a
|
|
70
|
+
`whoami` call at startup decides; a non-admin key's session never sees them):
|
|
71
|
+
|
|
72
|
+
- `admin_list_plant_types` — filtered/paged catalog rows: status, fuzzy search, genus, a species'
|
|
73
|
+
cultivar children, a missing field, or open proposals.
|
|
74
|
+
- `admin_get_plant_type` — one type's full detail: identity/status, parent/children, names,
|
|
75
|
+
own/inherited/effective values, plant and user counts, open proposals, open duplicate
|
|
76
|
+
candidates, image count, and its recent change log. A merged type still resolves —
|
|
77
|
+
`mergedIntoId` names the redirect.
|
|
78
|
+
- `admin_get_plant_type_field_schema` — every guidance field's kind, bounds, allowed values, and
|
|
79
|
+
what a scale value or unit means, derived from the same registry the app validates writes
|
|
80
|
+
against.
|
|
81
|
+
- `admin_list_duplicate_candidates` — the duplicate-detection queue: matched rule, score, and the
|
|
82
|
+
fields where a pair actually disagrees.
|
|
83
|
+
- `admin_list_plant_type_proposals` — the proposal review queue, each changed field carrying the
|
|
84
|
+
type's current value and a `stale` flag using the exact check a human reviewer's accept button
|
|
85
|
+
re-runs.
|
|
86
|
+
- `admin_preview_plant_type_merge` — the same comparison the admin merge confirmation dialog
|
|
87
|
+
loads: both sides' values, the per-field carry-over plan and its defaults for both directions,
|
|
88
|
+
counts that would move, and the guard error for either direction, if any.
|
|
89
|
+
|
|
90
|
+
**Five write tools**, added alongside the reads above, every description prefixed `ADMIN WRITE:`
|
|
91
|
+
and every one requiring a non-empty `note` recorded on the type's change log:
|
|
92
|
+
|
|
93
|
+
- `admin_create_plant_type` — add a new type: genus (required), species, cultivar, an initial name
|
|
94
|
+
list, catalog field values, and status (defaults `VERIFIED`). A cultivar attaches to its existing
|
|
95
|
+
species-level parent automatically; a missing parent is reported (`missingParentBotanicalKey`)
|
|
96
|
+
rather than auto-created. When the new type is itself species-level, existing orphans waiting for
|
|
97
|
+
it re-parent automatically (`adoptedOrphanIds`).
|
|
98
|
+
- `admin_update_plant_type_fields` — patch a type's own catalog field values; a value set to `null`
|
|
99
|
+
clears it back to inherited.
|
|
100
|
+
- `admin_set_plant_type_names` — replace a type's full name list.
|
|
101
|
+
- `admin_update_plant_type_botanical_identity` — edit genus/species/cultivar, which re-derives
|
|
102
|
+
`parentId` and re-parents orphans the same way creation does. A `botanical_key_conflict` (HTTP
|
|
103
|
+
409, `conflictingTypeId`) means the target identity already exists — the intended response is
|
|
104
|
+
`admin_preview_plant_type_merge` and a merge in the app, not forcing the edit through.
|
|
105
|
+
- `admin_verify_plant_type` — promote an `UNVERIFIED` type to `VERIFIED`.
|
|
106
|
+
|
|
107
|
+
**Six more write tools**, added alongside the five above, closing the gap the previous
|
|
108
|
+
release still left UI-only — merging duplicates and reviewing proposals/duplicate candidates:
|
|
109
|
+
|
|
110
|
+
- `admin_merge_plant_types` — merge `sourceId` into `targetId`. **Irreversible**: `sourceId` becomes
|
|
111
|
+
a `MERGED` redirect and every plant, name, image, cultivar child, pending proposal, legacy
|
|
112
|
+
mapping, and open duplicate candidate that pointed at it moves onto `targetId`.
|
|
113
|
+
`admin_preview_plant_type_merge` — call it first — already existed; this is the write half.
|
|
114
|
+
`fieldChoices` picks a side only for the fields the preview lists as conflicting.
|
|
115
|
+
- `admin_accept_plant_type_proposal` — accept a pending proposal in full or (via `fields`) in part.
|
|
116
|
+
A field whose current value has moved on since the proposal was filed fails the whole call with
|
|
117
|
+
HTTP 409 (`details.staleFields`) rather than an `allowStale` override.
|
|
118
|
+
- `admin_deny_plant_type_proposal` — deny a pending proposal. Never touches the type's own change
|
|
119
|
+
log — a denial is private to its author and the reviewer.
|
|
120
|
+
- `admin_deny_plant_type_proposals` — bulk-deny every `PENDING` proposal on one type, optionally by
|
|
121
|
+
`origin`, for clearing a batch of migration-filed proposals in one call.
|
|
122
|
+
- `admin_dismiss_duplicate_candidate` — dismiss an `OPEN` duplicate pair as not a duplicate. `note`
|
|
123
|
+
is required for a consistent tool contract but **not stored**: the candidate row has no column
|
|
124
|
+
for one.
|
|
125
|
+
- `admin_rescan_duplicate_candidates` — re-run duplicate detection against the live catalog and
|
|
126
|
+
reconcile the queue; no note needed, since it makes no decision.
|
|
127
|
+
|
|
128
|
+
Still UI-only, not covered by this release: nothing that was already exposed as a read here.
|
|
129
|
+
Deleting/unlinking a type, image review, and the AI plausibility check remain deliberately
|
|
130
|
+
excluded, not just deferred — see the "Admin catalog surface" section of `docs/mcp-server.md` for
|
|
131
|
+
why.
|
|
132
|
+
|
|
6
133
|
## 1.8.0 — 2026-08-08
|
|
7
134
|
|
|
8
135
|
**Plants can now be created, propagated, and merged from here.** Four new tools — 33 total (19
|
package/README.md
CHANGED
|
@@ -60,18 +60,26 @@ 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
|
-
**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
**56 tools** over your collection: 22 that read it, 17 that write to it. **17 admin tools** are also
|
|
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, and (6 read-only, 11
|
|
66
|
+
that write) reading, directly editing, merging, and reviewing proposals and duplicate candidates in
|
|
67
|
+
that catalog. 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, and the lookup catalogs. Writes cover recording waterings and fertilizings,
|
|
72
|
+
health notes, dismissing care recommendations, editing a plant's identity/care schedule/settings,
|
|
73
|
+
adding a plant (identified or not), propagating one, merging plants together and reversing that
|
|
74
|
+
merge, deleting a watering or fertilization event, and managing the wishlist (add, note, remove).
|
|
75
|
+
|
|
76
|
+
**Plant identity moved to a shared, community-maintained catalog in 2.0.0** — see **Breaking changes
|
|
77
|
+
in 2.0.0** below before upgrading a client that stored a `plantTypeId`.
|
|
70
78
|
|
|
71
79
|
**Deleting a plant, a photo, or a catalog entry is not possible from here** — that stays UI-only.
|
|
72
80
|
`delete_watering_event` and `delete_fertilization_event` are the two exceptions (see **What this
|
|
73
81
|
server deliberately cannot do** below). Reads need only the `read` scope that every key has;
|
|
74
|
-
writes need a key created with the `write` scope.
|
|
82
|
+
writes need a key created with the `write` scope; the admin tools need the separate `admin` scope.
|
|
75
83
|
|
|
76
84
|
Every tool is a thin proxy over the app's `/api/v1/*` REST API. Results are scoped to the key's
|
|
77
85
|
owner, and the server holds no logic of its own — which is why it stays correct as the app grows.
|
|
@@ -98,8 +106,14 @@ healthy server, not a hang — press Ctrl-C.
|
|
|
98
106
|
|
|
99
107
|
The scope lives on the API key, is enforced by the app, and cannot be widened from this side.
|
|
100
108
|
|
|
101
|
-
- **`read`** — on every key. Gates all
|
|
102
|
-
- **`write`** — opt-in when you create the key. Gates the
|
|
109
|
+
- **`read`** — on every key. Gates all 22 read tools.
|
|
110
|
+
- **`write`** — opt-in when you create the key. Gates the 17 write tools.
|
|
111
|
+
- **`admin`** — only offered when the key's creator is themselves an admin, and only while they
|
|
112
|
+
still are one (the app re-checks this on every admin-tool call, not just at key creation). Gates
|
|
113
|
+
the 17 admin catalog tools — 6 read-only, 11 that write — see **Admin tools** below. `admin` alone
|
|
114
|
+
is enough for the write ones too (not also `write` — the three scopes are independent grants). An
|
|
115
|
+
admin's key is created with `read`+`write`+`admin` together, so it also works with every ordinary
|
|
116
|
+
tool.
|
|
103
117
|
|
|
104
118
|
| Situation | HTTP | What the assistant sees |
|
|
105
119
|
|---|---|---|
|
|
@@ -114,11 +128,13 @@ point of the scope split.
|
|
|
114
128
|
|
|
115
129
|
Not oversights — deliberate limits:
|
|
116
130
|
|
|
117
|
-
- **No deletes, except two.** Deleting a plant, a photo, or a catalog entry
|
|
118
|
-
blast radius of a misunderstood delete there is a multi-year history, and
|
|
119
|
-
*status* change instead. `delete_watering_event` and
|
|
120
|
-
exceptions: a mis-logged event has no such reversible
|
|
121
|
-
and **cannot be undone**.
|
|
131
|
+
- **No deletes of collection history, except two.** Deleting a plant, a photo, or a catalog entry
|
|
132
|
+
stays UI-only — the blast radius of a misunderstood delete there is a multi-year history, and
|
|
133
|
+
retiring a plant is a *status* change instead. `delete_watering_event` and
|
|
134
|
+
`delete_fertilization_event` are the exceptions: a mis-logged event has no such reversible
|
|
135
|
+
alternative, so those two are destructive and **cannot be undone**. `remove_from_wishlist` is a
|
|
136
|
+
third delete, but not in the same sense — a wish is a personal to-do item with no history to
|
|
137
|
+
lose, trivially re-added with `add_to_wishlist` if removed by mistake.
|
|
122
138
|
- **No sales or trades.** Those records name a second person who never consented to your API key.
|
|
123
139
|
- **No share-link creation.** Minting a public URL for your collection is a decision for the UI.
|
|
124
140
|
- **No cross-user access.** Ownership comes from the key; a tool has no way to even express
|
|
@@ -133,14 +149,24 @@ Not oversights — deliberate limits:
|
|
|
133
149
|
| Tool | Endpoint | Arguments |
|
|
134
150
|
|---|---|---|
|
|
135
151
|
| `whoami` | `GET /api/v1/me` | none — who the key belongs to |
|
|
136
|
-
| `list_plants` | `GET /api/v1/plants` | all optional: `status`, `lifecycle`, `locationId`, `plantTypeId
|
|
137
|
-
| `get_plant` | `GET /api/v1/plants/:id` | `id` **(required)** — full detail: catalogs, care config, lineage, recent events |
|
|
152
|
+
| `list_plants` | `GET /api/v1/plants` | all optional: `status`, `lifecycle`, `locationId`, `plantTypeId` (a **global** catalog id), `soilId`, `fertilizerId`, `tag` (exact match), `search` (nickname / type names / notes), `limit` (1–200), `offset`, `sort` (`displayName`\|`createdAt`\|`updatedAt`\|`acquiredAt`) |
|
|
153
|
+
| `get_plant` | `GET /api/v1/plants/:id` | `id` **(required)** — full detail: identity (`type`), `typeFacts`, catalogs, care config, lineage, recent events |
|
|
138
154
|
|
|
139
155
|
`status` is one of `LIVING`, `DEAD`, `GIFTED`, `LOST`, `SOLD`, `TRADED`, `MERGED`, `SPLIT`,
|
|
140
156
|
`EXTERNAL`; `lifecycle` is `PROPAGATING` or `ESTABLISHED`. `EXTERNAL` is a **reference plant** —
|
|
141
157
|
somebody else's plant, recorded only as an anchor for propagation lineage. It is never cared for,
|
|
142
158
|
never appears in due/overdue care, and is excluded from `collection_stats`.
|
|
143
159
|
|
|
160
|
+
Each plant's identity is a `type` object (replacing the old `plantType`):
|
|
161
|
+
`{ plantTypeId, nickname, displayName, botanicalName, typeStatus, isUnidentified }`.
|
|
162
|
+
`plantTypeId` is `null` for an "unidentified" plant — one with no catalog type, just a `nickname`.
|
|
163
|
+
`get_plant` additionally returns `typeFacts` — the type's effective (inheritance-resolved) catalog
|
|
164
|
+
facts (light, water, feeding need, toxicity, climate, growth, substrate/propagation, family/native
|
|
165
|
+
region), keyed by the same field names `get_plant_type` uses — and its six schedule fields
|
|
166
|
+
(`wateringFrequencySummerDays`/`WinterDays`, `wateringFrequencySummer`/`Winter`,
|
|
167
|
+
`fertilizingCycleSummerWeeks`/`WinterWeeks`) as `{ value, source: "plant"|"type"|"none", typeValue }`
|
|
168
|
+
instead of a raw value — see **Breaking changes in 2.0.0** below.
|
|
169
|
+
|
|
144
170
|
### Per-plant history
|
|
145
171
|
|
|
146
172
|
All five take the same arguments: `id` **(required)**, `limit` (1–500), `offset`. Newest first.
|
|
@@ -210,23 +236,53 @@ the trip. Returns `{ hasVacation: false }` when there's no upcoming or running s
|
|
|
210
236
|
this tool never creates or modifies one. `locationId` narrows the plant list to one location; the
|
|
211
237
|
schedule always covers the whole vacation.
|
|
212
238
|
|
|
239
|
+
### Wishlist
|
|
240
|
+
|
|
241
|
+
| Tool | Endpoint | Arguments |
|
|
242
|
+
|---|---|---|
|
|
243
|
+
| `list_wishlist` | `GET /api/v1/wishlist` | none |
|
|
244
|
+
|
|
245
|
+
Plants the caller wants but doesn't own yet. Each entry carries `plantTypeId` (when it's a global
|
|
246
|
+
catalog type, with its display/botanical name and `typeStatus`) or a free-text `name` fallback for
|
|
247
|
+
a species not yet in the catalog, a `notes` array (dated, e.g. price or where to get it), and
|
|
248
|
+
`ownedLivingCount` — how many `LIVING` plants of that type the caller already has.
|
|
249
|
+
|
|
213
250
|
### Catalogs
|
|
214
251
|
|
|
215
|
-
For resolving the ids the filters take.
|
|
252
|
+
For resolving the ids the filters take.
|
|
216
253
|
|
|
217
|
-
| Tool | Endpoint |
|
|
218
|
-
|
|
219
|
-
| `list_locations` | `GET /api/v1/locations` |
|
|
220
|
-
| `list_plant_types` | `GET /api/v1/plant-types` |
|
|
221
|
-
| `
|
|
222
|
-
| `
|
|
254
|
+
| Tool | Endpoint | Arguments |
|
|
255
|
+
|---|---|---|
|
|
256
|
+
| `list_locations` | `GET /api/v1/locations` | none |
|
|
257
|
+
| `list_plant_types` | `GET /api/v1/plant-types` | all optional: `query` (fuzzy search), `status` (`UNVERIFIED`\|`VERIFIED`), `inMyCollection`, `limit` (1–200), `offset` |
|
|
258
|
+
| `search_plant_types` | `GET /api/v1/plant-types` | `query` **(required)**, plus the same optional filters/paging as `list_plant_types` |
|
|
259
|
+
| `get_plant_type` | `GET /api/v1/plant-types/:id` | `id` **(required)** — one type's fact sheet |
|
|
260
|
+
| `list_soils` | `GET /api/v1/soils` | none |
|
|
261
|
+
| `list_fertilizers` | `GET /api/v1/fertilizers` | none |
|
|
262
|
+
|
|
263
|
+
**`list_plant_types` is the shared, community-maintained global catalog** — the same for every
|
|
264
|
+
user, not a per-user list. `list_plant_types` and `search_plant_types` are the same endpoint and
|
|
265
|
+
reader; they exist as two tools because browsing (no `query`) and searching (`query` required) are
|
|
266
|
+
different intents for a model to reach for, even though the underlying call is identical.
|
|
267
|
+
`inMyCollection: true` narrows to types the caller has at least one `LIVING` plant of — each row's
|
|
268
|
+
`viewerPlantsCount` says how many. A `MERGED` or `REJECTED` type never appears in either list.
|
|
269
|
+
|
|
270
|
+
`get_plant_type` returns `{ id, status, mergedIntoId, displayName, botanicalName, names, parent,
|
|
271
|
+
children, facts: { values, sources }, images, viewerPlantsCount, changeLog }`. `facts.values` are
|
|
272
|
+
the effective (inheritance-resolved) catalog values — the same field keys `get_plant`'s `typeFacts`
|
|
273
|
+
uses; `facts.sources` says `"own"` or `"inherited"` per field. `images` are approved URLs only. A
|
|
274
|
+
`MERGED` id **transparently resolves to its survivor** here (unlike the admin
|
|
275
|
+
`admin_get_plant_type`, which returns the redirect stub) — there is nothing for a non-admin caller
|
|
276
|
+
to do with the stub itself. A `REJECTED` id behaves the same way when its outcome was a merge
|
|
277
|
+
(`mergedIntoId` set); otherwise it resolves to the type's own now-`REJECTED` row, since there's no
|
|
278
|
+
survivor to point to.
|
|
223
279
|
|
|
224
280
|
---
|
|
225
281
|
|
|
226
282
|
## Write tools
|
|
227
283
|
|
|
228
|
-
**All
|
|
229
|
-
with `WRITE:` so a model cannot mistake one for a read.
|
|
284
|
+
**All 17 ordinary write tools require a `write`-scoped key**; a read-only key gets HTTP 403. Every
|
|
285
|
+
description starts with `WRITE:` so a model cannot mistake one for a read.
|
|
230
286
|
|
|
231
287
|
### Care logging
|
|
232
288
|
|
|
@@ -247,21 +303,35 @@ thing per plant.
|
|
|
247
303
|
|
|
248
304
|
| Tool | Endpoint | Arguments |
|
|
249
305
|
|---|---|---|
|
|
250
|
-
| `update_plant_care` | `PATCH /api/v1/plants/:id/care` | `plantId` **(required)**, plus any of `wateringMode` (`scheduled`\|`reservoir`\|`hydro`), `fertilizingMode` (`scheduled`\|`with_watering`), `
|
|
251
|
-
| `update_plant` | `PATCH /api/v1/plants/:id` | `plantId` **(required)**, plus any of `locationId`, `soilId`, `fertilizerId`, `quantity` (0–9999), `notes`, `tags` (replaces the full list), `sitterInstructions`, `parentPlantId` |
|
|
306
|
+
| `update_plant_care` | `PATCH /api/v1/plants/:id/care` | `plantId` **(required)**, plus any of `wateringMode` (`scheduled`\|`reservoir`\|`hydro`), `fertilizingMode` (`scheduled`\|`with_watering`), `wateringFrequencySummer`/`wateringFrequencyWinter` (labels), `wateringFrequencySummerDays`/`wateringFrequencyWinterDays` (1–365), `wateringNotes`, `fertilizingCycleSummerWeeks`/`fertilizingCycleWinterWeeks` (0–52), `fertilizerPercent` (0–1000), `fertilizingNotes` |
|
|
307
|
+
| `update_plant` | `PATCH /api/v1/plants/:id` | `plantId` **(required)**, plus any of `plantTypeId`, `nickname`, `locationId`, `soilId`, `fertilizerId`, `quantity` (0–9999), `notes`, `tags` (replaces the full list), `sitterInstructions`, `parentPlantId` |
|
|
252
308
|
|
|
253
309
|
Both take a `plantId` and edit **only the fields you pass** — an omitted field keeps its current
|
|
254
310
|
value, `null` clears it (unassigns a catalog id, or blanks a text field).
|
|
255
311
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
312
|
+
**`update_plant_care` no longer accepts `sunRequirement`/`waterRequirement`** — light and water need
|
|
313
|
+
are type-only facts now (`get_plant`'s `typeFacts`, or `get_plant_type`). `null` on
|
|
314
|
+
`wateringFrequencySummerDays`/`WinterDays`, `wateringFrequencySummer`/`Winter`, or
|
|
315
|
+
`fertilizingCycleSummerWeeks`/`WinterWeeks` now means **"follow the type"** — the plant's effective
|
|
316
|
+
schedule falls back to its type's suggested value (see `get_plant`'s `typeValue` on each schedule
|
|
317
|
+
field) rather than having no schedule at all; `null` on `wateringNotes`/`fertilizingNotes` still just
|
|
318
|
+
clears the note.
|
|
319
|
+
|
|
320
|
+
The watering fields only take effect while the plant's watering is on a schedule — a reservoir/hydro
|
|
321
|
+
plant has no cycle for them to configure, so they're silently ignored for one. The fertilizing-cycle
|
|
322
|
+
fields are likewise ignored while the plant fertilizes with every watering rather than on its own
|
|
323
|
+
cycle. Passing `wateringMode`/`fertilizingMode` in the **same call** as the fields they gate checks
|
|
324
|
+
against the *new* mode, not the current one — so switching a plant to `"reservoir"` while also
|
|
325
|
+
setting a watering cycle just drops that cycle rather than saving it. `careMode` (a legacy mirror of
|
|
326
|
+
`wateringMode`) updates itself; it isn't a field you set. This is also the tool that actually fixes a
|
|
327
|
+
`wateringCycleMismatch` recommendation — that recommendation's own text says "edit the plant," and
|
|
328
|
+
the mode causing the mismatch lives here.
|
|
329
|
+
|
|
330
|
+
`update_plant`'s `plantTypeId` points the plant at a **global** catalog type (from
|
|
331
|
+
`list_plant_types`/`search_plant_types`; a `MERGED` id transparently resolves to its survivor); `null`
|
|
332
|
+
makes the plant "unidentified". `nickname` is required whenever the plant's state *after* this edit
|
|
333
|
+
has no type — set together with `plantTypeId: null`, or already unidentified and not gaining a type
|
|
334
|
+
in this same call.
|
|
265
335
|
|
|
266
336
|
`update_plant`'s `locationId`/`soilId`/`fertilizerId` must be one of **your own** catalog ids (from
|
|
267
337
|
`list_locations`/`list_soils`/`list_fertilizers`) — an unowned or unknown id is rejected.
|
|
@@ -275,13 +345,16 @@ don't exist yet, `propagate_plant` creates them *and* sets the link in one call.
|
|
|
275
345
|
|
|
276
346
|
| Tool | Endpoint | Arguments |
|
|
277
347
|
|---|---|---|
|
|
278
|
-
| `create_plant` | `POST /api/v1/plants` | `plantTypeId`
|
|
348
|
+
| `create_plant` | `POST /api/v1/plants` | `plantTypeId` (optional — omit/`null` for unidentified), `nickname` (required when unidentified), `reference` (default `false`), `parentPlantId` |
|
|
279
349
|
| `propagate_plant` | `POST /api/v1/plants/:id/propagations` | `plantId` **(required)**, `count` (1–10, default 1) |
|
|
280
350
|
| `merge_plants` | `POST /api/v1/plants/merge` | `sourcePlantIds` **(required,** 1–200**)**, plus **exactly one of** `survivorPlantId` or `createNewFrom` |
|
|
281
351
|
| `unmerge_plant` | `POST /api/v1/plants/:id/unmerge` | `plantId` **(required)** |
|
|
282
352
|
|
|
283
|
-
`create_plant`
|
|
284
|
-
|
|
353
|
+
`create_plant`'s `plantTypeId` is now **optional**: pass an existing global type id (from
|
|
354
|
+
`list_plant_types`/`search_plant_types`) when the species is known, or omit it (or pass `null`) to
|
|
355
|
+
add an "unidentified" plant — `nickname` is then required, matching the app's plant form. Creating a
|
|
356
|
+
plant *type* is still a UI action, so if the species isn't in the catalog yet, that step happens in
|
|
357
|
+
the app; an unidentified plant can always be identified there later.
|
|
285
358
|
|
|
286
359
|
`reference: true` creates an `EXTERNAL` plant: somebody else's, recorded only so lineage has a real
|
|
287
360
|
node to point at. It gets no acquisition date, no care schedule, and no place in collection stats —
|
|
@@ -323,6 +396,17 @@ Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `noFerti
|
|
|
323
396
|
`recentlyRepottedAvoidFertilizer`, `noRecentPhoto`. `wateringCycleMismatch` is **not** dismissible —
|
|
324
397
|
it is a configuration contradiction, fixed by editing the plant rather than hidden.
|
|
325
398
|
|
|
399
|
+
### Wishlist
|
|
400
|
+
|
|
401
|
+
| Tool | Endpoint | Arguments |
|
|
402
|
+
|---|---|---|
|
|
403
|
+
| `add_to_wishlist` | `POST /api/v1/wishlist` | `plantTypeId` or `name` — at least one required |
|
|
404
|
+
| `add_wishlist_note` | `POST /api/v1/wishlist/:id/notes` | `wishId` **(required)**, `text` **(required,** 1–500 chars**)** |
|
|
405
|
+
| `remove_from_wishlist` | `DELETE /api/v1/wishlist/:id` | `wishId` **(required)** |
|
|
406
|
+
|
|
407
|
+
`add_to_wishlist` never creates a second wish for a type the caller already has — it returns the
|
|
408
|
+
existing one instead (`duplicate: true`), rather than erroring or silently doubling it up.
|
|
409
|
+
|
|
326
410
|
### Deleting events
|
|
327
411
|
|
|
328
412
|
| Tool | Endpoint | Arguments |
|
|
@@ -330,7 +414,9 @@ it is a configuration contradiction, fixed by editing the plant rather than hidd
|
|
|
330
414
|
| `delete_watering_event` | `DELETE /api/v1/plants/:id/watering-events/:eventId` | `plantId`, `eventId` — both **required** |
|
|
331
415
|
| `delete_fertilization_event` | `DELETE /api/v1/plants/:id/fertilization-events/:eventId` | `plantId`, `eventId` — both **required** |
|
|
332
416
|
|
|
333
|
-
**These are the only
|
|
417
|
+
**These are the only tools that delete collection history, and neither can be undone.** (`remove_from_wishlist`
|
|
418
|
+
also deletes a row, but a wish carries no history — see **What this server deliberately cannot do**
|
|
419
|
+
above.) Take `eventId`
|
|
334
420
|
from `list_watering_events` / `list_fertilization_events` / `list_care_events`. For a reservoir or
|
|
335
421
|
hydro plant, `delete_watering_event` deletes its refill/top-up event instead — the same routing
|
|
336
422
|
`record_watering` uses on the write side.
|
|
@@ -350,6 +436,121 @@ the response, not just the HTTP status.
|
|
|
350
436
|
|
|
351
437
|
---
|
|
352
438
|
|
|
439
|
+
## Admin tools (admin-scoped keys only)
|
|
440
|
+
|
|
441
|
+
**17 tools** over the shared, community-maintained global plant type catalog — every plant's
|
|
442
|
+
species/cultivar entry, one catalog for the whole app rather than per-user. They need the `admin`
|
|
443
|
+
scope (see **Permissions & scopes** above), so a non-admin key's session never even sees them: the
|
|
444
|
+
server calls `whoami` once at startup and only registers these tools when the response's `scopes`
|
|
445
|
+
includes `admin`.
|
|
446
|
+
|
|
447
|
+
### Read-only (6)
|
|
448
|
+
|
|
449
|
+
| Tool | Endpoint | Arguments |
|
|
450
|
+
|---|---|---|
|
|
451
|
+
| `admin_list_plant_types` | `GET /api/v1/admin/plant-types` | all optional: `status` (`UNVERIFIED`\|`VERIFIED`\|`MERGED`, defaults to every live type — excludes both `MERGED` and `REJECTED`; a `REJECTED` type has no filter value of its own here yet), `search` (names + botanical), `genus`, `parentId` (a species-level type's cultivar children), `missingField` (a field key from `admin_get_plant_type_field_schema` — only types missing it), `hasOpenProposals`, `limit` (1–200), `offset` |
|
|
452
|
+
| `admin_get_plant_type` | `GET /api/v1/admin/plant-types/:id` | `id` **(required)** — full detail: identity/status (including `REJECTED`, for an admin-rejected user-created type — see `mergedIntoId`), parent/children, names, own/inherited/effective values, plant/user counts, open proposals, open duplicate candidates, image count, recent change log |
|
|
453
|
+
| `admin_get_plant_type_field_schema` | `GET /api/v1/admin/plant-types/fields` | none — every guidance field's kind, bounds, allowed values, and what a scale value or unit means |
|
|
454
|
+
| `admin_list_duplicate_candidates` | `GET /api/v1/admin/plant-types/duplicates` | `status` (`OPEN`\|`DISMISSED`\|`MERGED`, defaults to `OPEN`), `limit` (1–200), `offset` |
|
|
455
|
+
| `admin_list_plant_type_proposals` | `GET /api/v1/admin/plant-types/proposals` | `status` (defaults to `PENDING`), `origin` (`user`\|`migration`), `typeId`, `limit` (1–200), `offset` |
|
|
456
|
+
| `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 |
|
|
457
|
+
|
|
458
|
+
`admin_list_plant_types` returns compact rows only (no full field values) — call `admin_get_plant_type`
|
|
459
|
+
for one type's complete fact sheet. Its `search` is fuzzy, the same matcher the admin catalog list
|
|
460
|
+
uses in the app. `missingField` and `hasOpenProposals` are the two filters built for finding
|
|
461
|
+
cleanup work: types with a gap in their data, and types with a change proposal still waiting on a
|
|
462
|
+
human.
|
|
463
|
+
|
|
464
|
+
`admin_get_plant_type_field_schema` is never hand-written — it reads off the same registry the app
|
|
465
|
+
validates writes against, so a value an assistant reads back from `admin_get_plant_type` (or would
|
|
466
|
+
write) can always be checked against real bounds/enum values first.
|
|
467
|
+
|
|
468
|
+
`admin_list_plant_type_proposals` and `admin_get_plant_type`'s `openProposals` both mark each
|
|
469
|
+
changed field `stale` when the type's value has moved on since the proposal was filed — the exact
|
|
470
|
+
check a human reviewer's accept button re-runs, so an assistant sees the same warning.
|
|
471
|
+
|
|
472
|
+
### Write (11)
|
|
473
|
+
|
|
474
|
+
Every description starts with `ADMIN WRITE:`, and **every one of these requires a non-empty `note`**
|
|
475
|
+
— it is recorded on the type's change log entry, so a catalog edit made by an assistant is always
|
|
476
|
+
traceable back to why — except `admin_dismiss_duplicate_candidate` (required for consistency but not
|
|
477
|
+
actually stored — see below) and `admin_rescan_duplicate_candidates` (no note at all — it makes no
|
|
478
|
+
decision). `admin` alone is enough to call these (not also `write` — the three API-key scopes are
|
|
479
|
+
independent grants).
|
|
480
|
+
|
|
481
|
+
| Tool | Endpoint | Arguments |
|
|
482
|
+
|---|---|---|
|
|
483
|
+
| `admin_create_plant_type` | `POST /api/v1/admin/plant-types` | `genus` **(required)**, `species`, `cultivar`, `names` (initial full list), `values` (initial catalog fields), `status` (`UNVERIFIED`\|`VERIFIED`, default `VERIFIED`), `note` **(required)** |
|
|
484
|
+
| `admin_update_plant_type_fields` | `PATCH /api/v1/admin/plant-types/:id/fields` | `id` **(required)**, `values` **(required,** partial — a key set to `null` clears it back to inherited**)**, `note` **(required)** |
|
|
485
|
+
| `admin_set_plant_type_names` | `PUT /api/v1/admin/plant-types/:id/names` | `id` **(required)**, `names` **(required,** the complete replacement list**)**, `note` **(required)** |
|
|
486
|
+
| `admin_update_plant_type_botanical_identity` | `PATCH /api/v1/admin/plant-types/:id/identity` | `id` **(required)**, `genus`, `species`, `cultivar`, `note` **(required)** |
|
|
487
|
+
| `admin_verify_plant_type` | `POST /api/v1/admin/plant-types/:id/verify` | `id` **(required)**, `note` **(required)** |
|
|
488
|
+
| `admin_merge_plant_types` | `POST /api/v1/admin/plant-types/merge` | `sourceId` **(required)**, `targetId` **(required)**, `fieldChoices` (per-field `"source"`\|`"target"`), `note` **(required)** |
|
|
489
|
+
| `admin_accept_plant_type_proposal` | `POST /api/v1/admin/plant-types/proposals/:id/accept` | `id` **(required)**, `fields` (subset to accept — omit for all), `note` **(required)** |
|
|
490
|
+
| `admin_deny_plant_type_proposal` | `POST /api/v1/admin/plant-types/proposals/:id/deny` | `id` **(required)**, `note` **(required)** |
|
|
491
|
+
| `admin_deny_plant_type_proposals` | `POST /api/v1/admin/plant-types/proposals/deny` | `typeId` **(required)**, `origin` (`user`\|`migration`), `note` **(required)** |
|
|
492
|
+
| `admin_dismiss_duplicate_candidate` | `POST /api/v1/admin/plant-types/duplicates/:id/dismiss` | `id` **(required)**, `note` **(required, not stored — see below)** |
|
|
493
|
+
| `admin_rescan_duplicate_candidates` | `POST /api/v1/admin/plant-types/duplicates/rescan` | none |
|
|
494
|
+
|
|
495
|
+
Each of the first six (identity/creation edits and merge) returns `{ type: <the same shape as
|
|
496
|
+
admin_get_plant_type>, ... }`, plus whatever the operation adds (`changed`, `changes`,
|
|
497
|
+
`adoptedOrphanIds`, `missingParentBotanicalKey`, or — for merge — the move counts). The proposal and
|
|
498
|
+
duplicate tools return only the review outcome (see below); they don't reshape a type's own record
|
|
499
|
+
the way an edit does.
|
|
500
|
+
|
|
501
|
+
`admin_create_plant_type`'s `cultivar` attaches the new type under the existing species-level type
|
|
502
|
+
with the matching `genus`/`species` key, named as `parentId` in the response. If that species
|
|
503
|
+
doesn't exist yet, `parentId` comes back `null` and `missingParentBotanicalKey` names the key to
|
|
504
|
+
create first (call `admin_create_plant_type` again, without a cultivar) — a cultivar's parent is
|
|
505
|
+
never auto-created. When the new type is itself species-level, any existing type with no parent
|
|
506
|
+
whose own key implies this one as parent is automatically re-parented onto it — returned as
|
|
507
|
+
`adoptedOrphanIds` — which is how creating a missing species repairs the tree instead of leaving its
|
|
508
|
+
cultivars stranded.
|
|
509
|
+
|
|
510
|
+
`admin_update_plant_type_botanical_identity` re-parents orphans the same way when an edit makes a
|
|
511
|
+
type species-level, and shares the same conflict rule as creation: a `botanical_key_conflict`
|
|
512
|
+
(HTTP 409, `conflictingTypeId` in the error's `details`) means another live type already owns the
|
|
513
|
+
resulting identity — call `admin_preview_plant_type_merge` and merge the two rather than forcing the
|
|
514
|
+
edit through. It also refuses to turn a type with cultivar children into a cultivar itself (v1's
|
|
515
|
+
tree is only two levels deep).
|
|
516
|
+
|
|
517
|
+
`admin_update_plant_type_fields` shares the field registry with the read tools — call
|
|
518
|
+
`admin_get_plant_type_field_schema` first for valid keys and bounds — and returns which fields
|
|
519
|
+
actually changed. Don't set a cultivar's value to the same thing it already inherits; leave it
|
|
520
|
+
`null` so it keeps inheriting instead of freezing a copy that drifts from the parent later.
|
|
521
|
+
|
|
522
|
+
`admin_set_plant_type_names` always replaces the **full** list — call `admin_get_plant_type` first
|
|
523
|
+
and send its complete `names` array back with your change, or names you leave out are dropped.
|
|
524
|
+
|
|
525
|
+
**`admin_merge_plant_types` is irreversible.** `sourceId` becomes a `MERGED` redirect stub, and every
|
|
526
|
+
plant, name, image, cultivar child, pending proposal, legacy mapping, and open duplicate candidate
|
|
527
|
+
that pointed at it moves onto `targetId`. Always call `admin_preview_plant_type_merge` first — same
|
|
528
|
+
comparison a human admin would see. `targetId` keeps its own identity, `parentId`, status, and cover
|
|
529
|
+
image; `fieldChoices` only picks a side for the fields the preview lists as `conflict`/`onlySource` —
|
|
530
|
+
everything else the plan defaults to adopting happens regardless.
|
|
531
|
+
|
|
532
|
+
**`admin_accept_plant_type_proposal`** accepts in full (omit `fields`, status `ACCEPTED`) or in part
|
|
533
|
+
(an explicit subset, status `PARTIAL` unless it happens to cover everything). A field whose current
|
|
534
|
+
value moved on since the proposal was filed fails the whole call with HTTP 409
|
|
535
|
+
(`details.staleFields`) unless it's excluded from `fields` — there is no `allowStale` override here
|
|
536
|
+
like the admin UI's "accept anyway" checkbox; handle that field with `admin_update_plant_type_fields`
|
|
537
|
+
and retry without it, or deny the proposal. **`admin_deny_plant_type_proposal`** never writes a
|
|
538
|
+
change log entry on the type — a denial is private to the proposal's author and reviewer.
|
|
539
|
+
**`admin_deny_plant_type_proposals`** is the bulk variant for clearing every `PENDING` proposal on one
|
|
540
|
+
type at once (optionally by `origin`), for a batch of migration-filed proposals.
|
|
541
|
+
|
|
542
|
+
**`admin_dismiss_duplicate_candidate`'s `note` is required but not stored anywhere** —
|
|
543
|
+
`PlantTypeDuplicateCandidate` has no column for one; the requirement exists only for a consistent
|
|
544
|
+
tool contract, not a change log entry. A dismissed pair never resurfaces, even from
|
|
545
|
+
**`admin_rescan_duplicate_candidates`**, which re-runs detection against the whole live catalog and
|
|
546
|
+
reconciles the queue — the same "Rescan" button in the admin Duplicates tab, for when catalog edits
|
|
547
|
+
since the last migration run mean the queue is stale.
|
|
548
|
+
|
|
549
|
+
Deleting/unlinking a type, reviewing images, and the advisory AI plausibility check are deliberately
|
|
550
|
+
excluded even from the roadmap — see docs/mcp-server.md's Admin catalog surface section for why.
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
353
554
|
## Responses and errors
|
|
354
555
|
|
|
355
556
|
Tools return the API's JSON **verbatim**, pretty-printed. Nothing is reshaped, renamed, or
|
|
@@ -365,6 +566,34 @@ can act on them or explain them to you:
|
|
|
365
566
|
|
|
366
567
|
A non-JSON body is passed through as raw text rather than being swallowed.
|
|
367
568
|
|
|
569
|
+
## Breaking changes in 2.0.0
|
|
570
|
+
|
|
571
|
+
Plant identity moved from a per-user catalog to a shared, community-maintained one. If your client
|
|
572
|
+
(or a cached tool call) held onto a `plantTypeId` from before this release, it needs re-resolving —
|
|
573
|
+
the id spaces are unrelated. The full rationale is in the app's `docs/mcp-server.md`; the mechanical
|
|
574
|
+
changes:
|
|
575
|
+
|
|
576
|
+
- **`plantTypeId` means a global catalog id everywhere it appears** (`list_plants`, `get_plant`,
|
|
577
|
+
`create_plant`, `update_plant`) — not the old per-user type id. Re-resolve any id you cached before
|
|
578
|
+
this release via `list_plant_types`/`search_plant_types`.
|
|
579
|
+
- **`list_plant_types` now serves the global catalog**, not the caller's own types — every caller
|
|
580
|
+
sees the same rows now, differing only in `viewerPlantsCount`.
|
|
581
|
+
- **New: `search_plant_types` and `get_plant_type`** — see **Catalogs** above.
|
|
582
|
+
- **`plantTypeId` is now optional on `create_plant` and `update_plant`** — omit it (or pass `null`)
|
|
583
|
+
for an "unidentified" plant. `nickname` becomes required whenever the plant ends up with no type.
|
|
584
|
+
- **`get_plant`'s `plantType` field is replaced by `type`** — see its row above for the new shape
|
|
585
|
+
(`plantTypeId`, `nickname`, `displayName`, `botanicalName`, `typeStatus`, `isUnidentified`).
|
|
586
|
+
`list_plants` rows changed the same way.
|
|
587
|
+
- **`get_plant` gained `typeFacts`** (the type's effective light/water/feeding need, toxicity,
|
|
588
|
+
climate, growth, substrate/propagation, family/native region).
|
|
589
|
+
- **`get_plant`'s six schedule fields are no longer raw values** — each is now
|
|
590
|
+
`{ value, source, typeValue }`. A consumer reading e.g. `plant.wateringFrequencySummerDays` as a
|
|
591
|
+
number directly will break; read `.value` instead.
|
|
592
|
+
- **`update_plant_care` no longer accepts `sunRequirement`/`waterRequirement`** — both are type-only
|
|
593
|
+
facts now.
|
|
594
|
+
- **`null` on `update_plant_care`'s watering/fertilizing-cycle fields now means "follow the type"**,
|
|
595
|
+
not "no schedule" — see that tool's row above.
|
|
596
|
+
|
|
368
597
|
## Versioning
|
|
369
598
|
|
|
370
599
|
Semver against the **tool surface**, which is this package's public API:
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,12 @@ import { z } from "zod";
|
|
|
6
6
|
/**
|
|
7
7
|
* MCP server for the Sprig plant app: reads the collection, and — with a
|
|
8
8
|
* write-scoped key — logs care, edits a plant, and deletes a watering or
|
|
9
|
-
* fertilization event. Plants
|
|
9
|
+
* fertilization event. Plants and photos stay UI-only. An admin-scoped key
|
|
10
|
+
* additionally sees tools over the shared global plant type catalog
|
|
11
|
+
* (`admin_*`, registered conditionally — see `registerAdminToolsIfPermitted`):
|
|
12
|
+
* reads, creating and editing catalog entries directly, and (since this
|
|
13
|
+
* increment) merging duplicate types and reviewing proposals and duplicate
|
|
14
|
+
* candidates.
|
|
10
15
|
*
|
|
11
16
|
* Every tool is a thin proxy over the app's `/api/v1/*` REST endpoints,
|
|
12
17
|
* authenticated with a per-user API key sent as `Authorization: Bearer <key>`.
|
|
@@ -126,16 +131,16 @@ server.tool("list_plants", "List the user's plants, with optional filters. Retur
|
|
|
126
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."),
|
|
127
132
|
lifecycle: z.enum(["PROPAGATING", "ESTABLISHED"]).optional().describe("Filter by lifecycle stage."),
|
|
128
133
|
locationId: z.number().int().optional().describe("Filter by location id."),
|
|
129
|
-
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."),
|
|
130
135
|
soilId: z.number().int().optional().describe("Filter by soil id."),
|
|
131
136
|
fertilizerId: z.number().int().optional().describe("Filter by fertilizer id."),
|
|
132
137
|
tag: z.string().optional().describe("Filter by exact tag (case-sensitive)."),
|
|
133
|
-
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."),
|
|
134
139
|
limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
|
|
135
140
|
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
136
|
-
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."),
|
|
137
142
|
}, async (args) => apiGet("/api/v1/plants", args));
|
|
138
|
-
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}`));
|
|
139
144
|
// --- Events connected to a plant -------------------------------------------
|
|
140
145
|
const eventArgs = {
|
|
141
146
|
id: z.number().int().describe("Plant id."),
|
|
@@ -229,24 +234,24 @@ server.tool("record_fertilization", "WRITE: log a fertilization for one or more
|
|
|
229
234
|
.describe("Strength as a percentage of the base dose; defaults to each plant's own."),
|
|
230
235
|
fertilizedAt: optionalPlantDate(""),
|
|
231
236
|
}, async (args) => apiSend("POST", "/api/v1/care/fertilization", { ...args }));
|
|
232
|
-
server.tool("update_plant_care", "WRITE: edit a plant's care schedule — watering mode, days/labels,
|
|
237
|
+
server.tool("update_plant_care", "WRITE: edit a plant's care schedule — watering mode, days/labels, fertilizing mode, cycles and percent, and the watering/fertilizing notes. Omitted fields keep their current value. null on wateringFrequencySummerDays/WinterDays, wateringFrequencySummer/Winter, or fertilizingCycleSummerWeeks/WinterWeeks means 'follow the type' — the plant's effective schedule then falls back to its type's suggested value (see get_plant's typeFacts / the schedule fields' typeValue) instead of having no schedule at all; a value equal to the type's suggested value is stored the same way (it follows the type). null on wateringNotes/fertilizingNotes just clears the note. Watering fields are ignored while the plant's watering isn't on a schedule (reservoir/hydro), and the fertilizing-cycle fields are ignored while it fertilizes with every watering — there's no cycle to configure in either case. If wateringMode/fertilizingMode is part of this same call, that check runs against the NEW mode, not the plant's current one — e.g. switching to 'reservoir' while also passing a watering cycle ignores that cycle. sunRequirement/waterRequirement are no longer settable here — light and water need are type-only facts now (get_plant's typeFacts, or get_plant_type). Use this (not update_plant) to fix a 'wateringCycleMismatch' recommendation where the mode itself is wrong. Requires a write-scoped API key.", {
|
|
233
238
|
plantId: z.number().int().positive().describe("Plant id."),
|
|
234
239
|
wateringMode: z.enum(["scheduled", "reservoir", "hydro"]).optional().describe("How this plant is watered: on a schedule, from a reservoir, or in water/hydro culture."),
|
|
235
240
|
fertilizingMode: z.enum(["scheduled", "with_watering"]).optional().describe("How this plant is fertilized: on its own cycle, or automatically with every watering."),
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
wateringFrequencyWinter: z.string().max(50).nullable().optional().describe("Free-text winter watering label. Ignored unless watering is scheduled."),
|
|
241
|
-
wateringFrequencyWinterDays: z.number().int().min(1).max(365).optional().describe("Winter watering interval in days. Ignored unless watering is scheduled."),
|
|
241
|
+
wateringFrequencySummer: z.string().max(50).nullable().optional().describe("Free-text summer watering label, e.g. 'twice a week'. null follows the type's guidance. Ignored unless watering is scheduled."),
|
|
242
|
+
wateringFrequencySummerDays: z.number().int().min(1).max(365).nullable().optional().describe("Summer watering interval in days. null follows the type's suggested days. Ignored unless watering is scheduled."),
|
|
243
|
+
wateringFrequencyWinter: z.string().max(50).nullable().optional().describe("Free-text winter watering label. null follows the type's guidance. Ignored unless watering is scheduled."),
|
|
244
|
+
wateringFrequencyWinterDays: z.number().int().min(1).max(365).nullable().optional().describe("Winter watering interval in days. null follows the type's suggested days. Ignored unless watering is scheduled."),
|
|
242
245
|
wateringNotes: z.string().max(500).nullable().optional().describe("Free-text watering notes. Ignored unless watering is scheduled."),
|
|
243
|
-
fertilizingCycleSummerWeeks: z.number().int().min(0).max(52).optional().describe("Summer fertilizing interval in weeks. Ignored while fertilizing is 'with every watering'."),
|
|
244
|
-
fertilizingCycleWinterWeeks: z.number().int().min(0).max(52).optional().describe("Winter fertilizing interval in weeks. Ignored while fertilizing is 'with every watering'."),
|
|
246
|
+
fertilizingCycleSummerWeeks: z.number().int().min(0).max(52).nullable().optional().describe("Summer fertilizing interval in weeks. null follows the type's suggested weeks. Ignored while fertilizing is 'with every watering'."),
|
|
247
|
+
fertilizingCycleWinterWeeks: z.number().int().min(0).max(52).nullable().optional().describe("Winter fertilizing interval in weeks. null follows the type's suggested weeks. Ignored while fertilizing is 'with every watering'."),
|
|
245
248
|
fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose."),
|
|
246
249
|
fertilizingNotes: z.string().max(500).nullable().optional().describe("Free-text fertilizing notes."),
|
|
247
250
|
}, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}/care`, body));
|
|
248
|
-
server.tool("update_plant", "WRITE: edit a plant's location, soil, fertilizer, quantity, notes, tags, sitter instructions, or parent plant. Omitted fields keep their current value; null unassigns a catalog entry, clears notes/instructions, empties the tag list, or detaches the plant from its parent. Requires a write-scoped API key.", {
|
|
251
|
+
server.tool("update_plant", "WRITE: edit a plant's type, nickname, location, soil, fertilizer, quantity, notes, tags, sitter instructions, or parent plant. Omitted fields keep their current value; null unassigns a catalog entry, clears notes/instructions, empties the tag list, or detaches the plant from its parent — and for plantTypeId, makes the plant 'unidentified'. nickname is required whenever the plant's state after this edit has no type (whether because plantTypeId is set to null here, or was already null and nickname isn't being set to something non-empty) — see plantIdentity's validatePlantNickname. Requires a write-scoped API key.", {
|
|
249
252
|
plantId: z.number().int().positive().describe("Plant id."),
|
|
253
|
+
plantTypeId: z.number().int().positive().nullable().optional().describe("New global plant type id, from list_plant_types/search_plant_types/get_plant. A MERGED id transparently resolves to its survivor. null makes the plant unidentified — nickname is then required."),
|
|
254
|
+
nickname: z.string().max(100).nullable().optional().describe("The plant's own name, shown instead of the type's name when set. Required when the plant has (or is becoming) unidentified."),
|
|
250
255
|
locationId: z.number().int().positive().nullable().optional().describe("New location id, from list_locations. null unassigns it."),
|
|
251
256
|
soilId: z.number().int().positive().nullable().optional().describe("New soil id, from list_soils. null unassigns it."),
|
|
252
257
|
fertilizerId: z.number().int().positive().nullable().optional().describe("New fertilizer id, from list_fertilizers. null unassigns it."),
|
|
@@ -303,8 +308,9 @@ server.tool("delete_fertilization_event", "WRITE: delete a fertilization event f
|
|
|
303
308
|
eventId: z.number().int().positive().describe("Event id, from list_fertilization_events or list_care_events."),
|
|
304
309
|
}, async ({ plantId, eventId }) => apiSend("DELETE", `/api/v1/plants/${plantId}/fertilization-events/${eventId}`));
|
|
305
310
|
// --- Plant lifecycle (writes) ------------------------------------------------
|
|
306
|
-
server.tool("create_plant", "WRITE: add a new plant to the collection. plantTypeId is
|
|
307
|
-
plantTypeId: z.number().int().positive().describe("
|
|
311
|
+
server.tool("create_plant", "WRITE: add a new plant to the collection. plantTypeId is now optional (omit it, or pass null, to create an 'unidentified' plant) — call list_plant_types/search_plant_types first and pick the matching one when the species is known; creating a new plant type is not possible here and stays a UI action. nickname is required whenever plantTypeId is omitted/null, matching the app's plant form. Set reference: true to record somebody else's plant (status EXTERNAL): it is not one of the user's own plants, gets no acquisition date and no care schedule, and is excluded from collection stats — its purpose is to be a real node that propagation lineage can point at. Requires a write-scoped API key.", {
|
|
312
|
+
plantTypeId: z.number().int().positive().nullable().optional().describe("Global plant type id, from list_plant_types/search_plant_types. Omit (or pass null) for an unidentified plant — a MERGED id transparently resolves to its survivor."),
|
|
313
|
+
nickname: z.string().max(100).optional().describe("The plant's own name. Required when plantTypeId is omitted or null."),
|
|
308
314
|
reference: z.boolean().optional().describe("true creates an EXTERNAL reference plant (somebody else's, a lineage anchor only). Defaults to false: a LIVING plant of the user's own."),
|
|
309
315
|
parentPlantId: z.number().int().positive().optional().describe("Optional: the plant this one was propagated from."),
|
|
310
316
|
}, async (body) => apiSend("POST", "/api/v1/plants", { ...body }));
|
|
@@ -320,13 +326,213 @@ server.tool("merge_plants", "WRITE: merge several living plants into one — for
|
|
|
320
326
|
server.tool("unmerge_plant", "WRITE: reverse a merge for one plant — takes a MERGED plant back to LIVING and clears its link to the survivor. Undoes one source at a time, so call it per plant to fully reverse a multi-plant merge. The survivor is left in place; delete it in the UI if it was created by the merge and is no longer wanted. Requires a write-scoped API key.", {
|
|
321
327
|
plantId: z.number().int().positive().describe("The MERGED plant to restore."),
|
|
322
328
|
}, async ({ plantId }) => apiSend("POST", `/api/v1/plants/${plantId}/unmerge`));
|
|
329
|
+
// --- Wishlist ----------------------------------------------------------
|
|
330
|
+
/**
|
|
331
|
+
* The caller's personal wishlist (R2-15) — plants they want but don't own
|
|
332
|
+
* yet, either a global catalog type (`plantTypeId`) or a free-text name
|
|
333
|
+
* fallback for a species not in the catalog. Unlike sales/trades, wishes
|
|
334
|
+
* are never exposed-but-excluded: they're the caller's own data, about
|
|
335
|
+
* their own collection, so there's no second-person consent concern.
|
|
336
|
+
*/
|
|
337
|
+
server.tool("list_wishlist", "List the caller's wishlist: each wish's plant type (when set) with its display/botanical name, or a free-text name fallback, plus notes and how many LIVING plants of that type the caller already owns.", async () => apiGet("/api/v1/wishlist"));
|
|
338
|
+
server.tool("add_to_wishlist", "WRITE: add a wish — a global catalog type (plantTypeId, from list_plant_types/search results) or a free-text name for a species not in the catalog yet. At least one is required. Never creates a second wish for a type the caller already has — returns the existing one instead (duplicate: true). Requires a write-scoped API key.", {
|
|
339
|
+
plantTypeId: z.number().int().positive().optional().describe("An existing global plant type id."),
|
|
340
|
+
name: z.string().max(100).optional().describe("Free-text name fallback, for a species not yet in the catalog."),
|
|
341
|
+
}, async ({ plantTypeId, name }) => apiSend("POST", "/api/v1/wishlist", { plantTypeId, name }));
|
|
342
|
+
server.tool("add_wishlist_note", "WRITE: add a dated note to a wish — price, where to get it, a shop link, etc. Requires a write-scoped API key.", {
|
|
343
|
+
wishId: z.number().int().positive().describe("Wish id, from list_wishlist."),
|
|
344
|
+
text: z.string().min(1).max(500).describe("The note text."),
|
|
345
|
+
}, async ({ wishId, text }) => apiSend("POST", `/api/v1/wishlist/${wishId}/notes`, { text }));
|
|
346
|
+
server.tool("remove_from_wishlist", "WRITE: remove a wish (e.g. once it's been fulfilled by a new plant). Requires a write-scoped API key.", {
|
|
347
|
+
wishId: z.number().int().positive().describe("Wish id, from list_wishlist."),
|
|
348
|
+
}, async ({ wishId }) => apiSend("DELETE", `/api/v1/wishlist/${wishId}`));
|
|
323
349
|
// --- Catalogs (for resolving filter ids) -----------------------------------
|
|
324
350
|
server.tool("list_locations", "List the user's locations.", async () => apiGet("/api/v1/locations"));
|
|
325
|
-
|
|
351
|
+
/**
|
|
352
|
+
* R2-12: the global plant type catalog, shared by every user — not a
|
|
353
|
+
* per-user list any more. `list_plant_types` and `search_plant_types` are
|
|
354
|
+
* deliberately the same underlying endpoint/reader (`GET /api/v1/plant-types`
|
|
355
|
+
* — see `docs/mcp-server.md`'s "Catalog reads" section): browsing and
|
|
356
|
+
* searching are the same fuzzy-filtered, paged list, just with `query`
|
|
357
|
+
* required for one and optional for the other, so a model reaching for
|
|
358
|
+
* either gets the same result shape and paging behavior.
|
|
359
|
+
*/
|
|
360
|
+
const publicPlantTypeListArgs = {
|
|
361
|
+
status: z.enum(["UNVERIFIED", "VERIFIED"]).optional().describe("Filter by review status. Defaults to every live type (both)."),
|
|
362
|
+
inMyCollection: z.boolean().optional().describe("true: only types the caller has at least one LIVING plant of."),
|
|
363
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
|
|
364
|
+
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
365
|
+
};
|
|
366
|
+
server.tool("list_plant_types", "Browse the shared global plant type catalog — every plant's species/cultivar entry, one catalog for the whole app. Pass query to search by name/botanical; omit it to just browse (optionally filtered by status/inMyCollection). Compact rows only — no full field values or facts; use get_plant_type for one type's fact sheet. A MERGED type never appears here — see get_plant_type for how an id that was since merged resolves.", { query: z.string().optional().describe("Fuzzy search over names (all locales) and the botanical name."), ...publicPlantTypeListArgs }, async (args) => apiGet("/api/v1/plant-types", { ...args, inMyCollection: args.inMyCollection?.toString() }));
|
|
367
|
+
server.tool("search_plant_types", "Fuzzy search the shared global plant type catalog by name (all locales) or botanical name — the search-first counterpart to list_plant_types (same endpoint, query required here). Use this when the caller named a species/plant and you need its id, e.g. before create_plant or update_plant.", { query: z.string().min(1).describe("Search text — a common name in any language, or a botanical name."), ...publicPlantTypeListArgs }, async (args) => apiGet("/api/v1/plant-types", { ...args, inMyCollection: args.inMyCollection?.toString() }));
|
|
368
|
+
server.tool("get_plant_type", "Get one global plant type's fact sheet: identity and status, names by locale, parent/cultivar children, effective catalog facts (light, water, feeding need, toxicity, climate, growth, substrate/propagation, family/native region) with each field's source ('own' vs. 'inherited' from an ancestor), approved image URLs, and how many of the caller's own LIVING plants use it. A MERGED id transparently resolves to its survivor (mergedIntoId is still reported) rather than returning a redirect stub — unlike the admin equivalent.", { id: z.number().int().describe("Global plant type id, from list_plant_types/search_plant_types/get_plant.") }, async ({ id }) => apiGet(`/api/v1/plant-types/${id}`));
|
|
326
369
|
server.tool("list_soils", "List the user's soils.", async () => apiGet("/api/v1/soils"));
|
|
327
370
|
server.tool("list_fertilizers", "List the user's fertilizers.", async () => apiGet("/api/v1/fertilizers"));
|
|
371
|
+
// --- Admin: global plant type catalog (admin-scoped keys only) -------------
|
|
372
|
+
/**
|
|
373
|
+
* Tools over the shared, community-maintained plant type catalog — finding
|
|
374
|
+
* and understanding duplicates, restructuring the genus/species/cultivar
|
|
375
|
+
* tree, creating and editing catalog entries directly, and (since this
|
|
376
|
+
* increment) merging two types, reviewing a proposal, and dismissing or
|
|
377
|
+
* rescanning duplicate candidates. Every description starts with `ADMIN:`
|
|
378
|
+
* for a read or `ADMIN WRITE:` for a write; `check-tool-docs.mjs` classifies
|
|
379
|
+
* a tool by that prefix (`WRITE:` for an ordinary write tool, `ADMIN WRITE:`
|
|
380
|
+
* for an admin write, `ADMIN:` for an admin read, anything else an ordinary
|
|
381
|
+
* read) — see the comment at the top of that script.
|
|
382
|
+
*
|
|
383
|
+
* These are registered conditionally — see `registerAdminToolsIfPermitted`
|
|
384
|
+
* below — so a non-admin key's session never sees a tool it can't call.
|
|
385
|
+
* Every `ADMIN WRITE:` tool requires a non-empty `note`, recorded on the
|
|
386
|
+
* catalog's change log so an assistant's edit is always traceable, with two
|
|
387
|
+
* exceptions: `admin_dismiss_duplicate_candidate` requires one for
|
|
388
|
+
* consistency but has nowhere to store it (see its own description), and
|
|
389
|
+
* `admin_rescan_duplicate_candidates` needs none at all (no decision is
|
|
390
|
+
* being made). The same `admin` scope covers both the reads and the writes
|
|
391
|
+
* here (not also `write`; the three API-key scopes are independent grants,
|
|
392
|
+
* see docs/mcp-server.md's Scopes section).
|
|
393
|
+
*/
|
|
394
|
+
function registerAdminTools() {
|
|
395
|
+
server.tool("admin_list_plant_types", "ADMIN: list types in the shared global plant type catalog, with filters for finding gaps and problems (missing data, no open proposals reviewed yet). Compact rows only — no full field values; use admin_get_plant_type for one type's full fact sheet. Requires an admin-scoped API key.", {
|
|
396
|
+
status: z.enum(["UNVERIFIED", "VERIFIED", "MERGED"]).optional().describe("Filter by review status. Defaults to every non-MERGED type."),
|
|
397
|
+
search: z.string().optional().describe("Fuzzy search over names (all locales) and the botanical name."),
|
|
398
|
+
genus: z.string().optional().describe("Filter to an exact genus (case-insensitive)."),
|
|
399
|
+
parentId: z.number().int().optional().describe("Filter to the cultivar children of this species-level type id."),
|
|
400
|
+
missingField: z
|
|
401
|
+
.string()
|
|
402
|
+
.optional()
|
|
403
|
+
.describe("A catalog field key (from admin_get_plant_type_field_schema) — only types with no own value for it."),
|
|
404
|
+
hasOpenProposals: z.boolean().optional().describe("true: only types with a pending proposal awaiting review."),
|
|
405
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
|
|
406
|
+
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
407
|
+
}, async (args) => apiGet("/api/v1/admin/plant-types", { ...args, hasOpenProposals: args.hasOpenProposals?.toString() }));
|
|
408
|
+
server.tool("admin_get_plant_type", "ADMIN: full detail for one global plant type: identity and status, parent/children, names, own/inherited/effective catalog values, plant and user counts, open proposals awaiting review, open duplicate candidates, image count, and its recent change log. A MERGED type still resolves — its own row persists — so `mergedIntoId` names the type it was merged into; follow it rather than treating this as a 404. Requires an admin-scoped API key.", { id: z.number().int().describe("Global plant type id.") }, async ({ id }) => apiGet(`/api/v1/admin/plant-types/${id}`));
|
|
409
|
+
server.tool("admin_get_plant_type_field_schema", "ADMIN: the catalog field registry — every editable guidance field's key, kind, bounds, allowed enum/vocabulary values, whether it's a closed scale and what each scale value means, and its unit/group. Call this before writing any field value with admin_update_plant_type_fields; it is also how to read admin_get_plant_type's own/inherited/effective values correctly. Requires an admin-scoped API key.", async () => apiGet("/api/v1/admin/plant-types/fields"));
|
|
410
|
+
server.tool("admin_list_duplicate_candidates", "ADMIN: the duplicate-candidate queue — pairs of types the catalog's duplicate detection flagged as possibly the same plant, with the matched rule and the catalog fields where they actually disagree. Defaults to OPEN (unreviewed) candidates. Requires an admin-scoped API key.", {
|
|
411
|
+
status: z.enum(["OPEN", "DISMISSED", "MERGED"]).optional().describe("Defaults to OPEN."),
|
|
412
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
|
|
413
|
+
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
414
|
+
}, async (args) => apiGet("/api/v1/admin/plant-types/duplicates", args));
|
|
415
|
+
server.tool("admin_list_plant_type_proposals", "ADMIN: the proposal review queue — per-field change requests filed against a type, each with the type's current value and a `stale` flag when the type changed since the proposal was filed. Defaults to PENDING. Requires an admin-scoped API key.", {
|
|
416
|
+
status: z.enum(["PENDING", "ACCEPTED", "PARTIAL", "DENIED", "WITHDRAWN"]).optional().describe("Defaults to PENDING."),
|
|
417
|
+
origin: z.enum(["user", "migration"]).optional().describe("Filter by who filed the proposal."),
|
|
418
|
+
typeId: z.number().int().optional().describe("Only proposals for this plant type id."),
|
|
419
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
|
|
420
|
+
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
421
|
+
}, async (args) => apiGet("/api/v1/admin/plant-types/proposals", args));
|
|
422
|
+
server.tool("admin_preview_plant_type_merge", "ADMIN: preview merging two catalog types before doing it with admin_merge_plant_types. Returns both sides' identity/own values, the per-field carry-over plan and its defaults for both merge directions, counts that would move, and a guard error naming why a direction is disallowed, if any. Always call this before merging — the merge itself is irreversible. Requires an admin-scoped API key.", {
|
|
423
|
+
sourceId: z.number().int().describe("The type that would be merged away."),
|
|
424
|
+
targetId: z.number().int().describe("The type that would survive."),
|
|
425
|
+
}, async ({ sourceId, targetId }) => apiGet("/api/v1/admin/plant-types/merge-preview", { sourceId, targetId }));
|
|
426
|
+
const catalogFieldValues = z
|
|
427
|
+
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string()), z.null()]))
|
|
428
|
+
.describe("Field values keyed by field name — call admin_get_plant_type_field_schema first for valid keys, kinds, and bounds. null clears a value.");
|
|
429
|
+
const catalogNameEntry = z.object({
|
|
430
|
+
name: z.string().min(1).describe("The name text."),
|
|
431
|
+
locale: z.enum(["de", "en"]).nullable().describe("'de', 'en', or null for any other language."),
|
|
432
|
+
isPrimary: z.boolean().describe("Whether this is the display name shown for its locale."),
|
|
433
|
+
});
|
|
434
|
+
server.tool("admin_create_plant_type", "ADMIN WRITE: create a new type in the shared global plant type catalog. genus is required; species/cultivar are optional and determine the botanical identity key. Setting cultivar attaches the new type as a cultivar under the existing species-level type with the matching genus|species key, if one exists — parentId in the response names it. If no such species exists yet, parentId comes back null and missingParentBotanicalKey names the key to create first (call this tool again without a cultivar, then retry): a cultivar's parent is never auto-created. When the new type is itself species-level (no cultivar), any existing type with no parent whose own key implies this one as parent is automatically re-parented onto it — returned as adoptedOrphanIds — which is how creating a missing species repairs the tree. Fails with a 409 conflict (conflictingTypeId in the error details) when another live type already owns the resulting identity; call admin_preview_plant_type_merge and merge them in the app instead of forcing this. status defaults to VERIFIED (an admin vouching for the new type); pass UNVERIFIED to leave it for review. note is required and recorded on the change log. Requires an admin-scoped API key.", {
|
|
435
|
+
genus: z.string().min(1).describe("Genus, required."),
|
|
436
|
+
species: z.string().nullable().optional().describe("Species epithet. Omit or null for a genus-only/unidentified-species type."),
|
|
437
|
+
cultivar: z.string().nullable().optional().describe("Cultivar name. Setting this makes the new type a cultivar attached under the matching species-level type."),
|
|
438
|
+
names: z.array(catalogNameEntry).optional().describe("Initial full name list. Defaults to none."),
|
|
439
|
+
values: catalogFieldValues.optional().describe("Optional initial catalog field values."),
|
|
440
|
+
status: z.enum(["UNVERIFIED", "VERIFIED"]).optional().describe("Review status. Defaults to VERIFIED."),
|
|
441
|
+
note: z.string().min(1).describe("Required note, recorded on the new type's change log."),
|
|
442
|
+
}, async ({ names, values, status, note, ...identity }) => apiSend("POST", "/api/v1/admin/plant-types", { ...identity, names, values, status, note }));
|
|
443
|
+
server.tool("admin_update_plant_type_fields", "ADMIN WRITE: edit a global plant type's own catalog field values. Call admin_get_plant_type_field_schema first for valid keys/bounds, and admin_get_plant_type to see current own/inherited/effective values. values is a partial patch — only the keys you pass change. Set a key to null to clear the type's own value so it inherits from its parent chain again; do not set a cultivar's value to the same thing it already inherits, since that freezes a copy that will drift from the parent later — leave it null instead. Returns which fields actually changed. note is required and recorded on the change log. Requires an admin-scoped API key.", {
|
|
444
|
+
id: z.number().int().describe("Global plant type id."),
|
|
445
|
+
values: catalogFieldValues,
|
|
446
|
+
note: z.string().min(1).describe("Required note, recorded on the change log."),
|
|
447
|
+
}, async ({ id, values, note }) => apiSend("PATCH", `/api/v1/admin/plant-types/${id}/fields`, { values, note }));
|
|
448
|
+
server.tool("admin_set_plant_type_names", "ADMIN WRITE: replace a global plant type's full name list. This is a full replacement, not a patch — call admin_get_plant_type first and send its complete names array back with your change, or names you leave out are dropped. isPrimary picks the display name shown per locale ('de'/'en'/null for other); at most one primary per locale. note is required and recorded on the change log. Requires an admin-scoped API key.", {
|
|
449
|
+
id: z.number().int().describe("Global plant type id."),
|
|
450
|
+
names: z.array(catalogNameEntry).describe("The complete replacement name list."),
|
|
451
|
+
note: z.string().min(1).describe("Required note, recorded on the change log."),
|
|
452
|
+
}, async ({ id, names, note }) => apiSend("PUT", `/api/v1/admin/plant-types/${id}/names`, { names, note }));
|
|
453
|
+
server.tool("admin_update_plant_type_botanical_identity", "ADMIN WRITE: edit a global plant type's genus/species/cultivar. This also moves the type in the tree: parentId is re-derived from the new genus|species key, attaching to the matching species-level type if one exists. When the edit makes the type species-level, any existing orphaned type whose own key implies this one as parent is automatically re-parented onto it, returned as adoptedOrphanIds — same as admin_create_plant_type. Fails with a 409 conflict (conflictingTypeId in the error details) when another live type already owns the resulting identity — that means these are duplicates: call admin_preview_plant_type_merge and merge them in the app instead of forcing this edit. Refuses to turn a type with cultivar children into a cultivar itself. note is required and recorded on the change log. Requires an admin-scoped API key.", {
|
|
454
|
+
id: z.number().int().describe("Global plant type id."),
|
|
455
|
+
genus: z.string().nullable().describe("New genus, or null to clear it (only valid when the type still has a name to fall back on as its identity)."),
|
|
456
|
+
species: z.string().nullable().describe("New species epithet, or null."),
|
|
457
|
+
cultivar: z.string().nullable().describe("New cultivar name, or null."),
|
|
458
|
+
note: z.string().min(1).describe("Required note, recorded on the change log."),
|
|
459
|
+
}, async ({ id, genus, species, cultivar, note }) => apiSend("PATCH", `/api/v1/admin/plant-types/${id}/identity`, { genus, species, cultivar, note }));
|
|
460
|
+
server.tool("admin_verify_plant_type", "ADMIN WRITE: promote an UNVERIFIED global plant type to VERIFIED. note is required and recorded on the change log. Requires an admin-scoped API key.", {
|
|
461
|
+
id: z.number().int().describe("Global plant type id."),
|
|
462
|
+
note: z.string().min(1).describe("Required note, recorded on the change log."),
|
|
463
|
+
}, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/${id}/verify`, { note }));
|
|
464
|
+
const catalogFieldChoices = z
|
|
465
|
+
.record(z.string(), z.enum(["source", "target"]))
|
|
466
|
+
.describe("Keyed by field name — picks which side wins for a catalog field admin_preview_plant_type_merge lists as conflict/onlySource. Omit a field (or omit this whole argument) to take the plan's own default for it.");
|
|
467
|
+
server.tool("admin_merge_plant_types", "ADMIN WRITE: merge sourceId into targetId in the shared global plant type catalog. This is IRREVERSIBLE: sourceId becomes a MERGED redirect stub, and every plant, name, image, cultivar child, pending proposal, legacy mapping, and open duplicate candidate that pointed at it moves onto targetId. Always call admin_preview_plant_type_merge first — it shows the exact same comparison a human admin would see (both sides' values, the per-field carry-over plan and its defaults, counts that would move, and why a direction might be disallowed) before you commit to this. targetId keeps its own identity, parentId, status, and cover image unchanged; fieldChoices only picks which side's value wins for the catalog fields the preview lists as conflict or onlySource — everything else the plan defaults to adopting happens regardless of fieldChoices. Returns the survivor's full detail (same shape as admin_get_plant_type) plus the move counts. note is required and recorded on the change log of both types. Requires an admin-scoped API key.", {
|
|
468
|
+
sourceId: z.number().int().describe("The type that will be merged away (becomes a MERGED redirect)."),
|
|
469
|
+
targetId: z.number().int().describe("The type that will survive and receive everything from sourceId."),
|
|
470
|
+
note: z.string().min(1).describe("Required note, recorded on the change log of both types."),
|
|
471
|
+
fieldChoices: catalogFieldChoices.optional(),
|
|
472
|
+
}, async ({ sourceId, targetId, note, fieldChoices }) => apiSend("POST", "/api/v1/admin/plant-types/merge", { sourceId, targetId, note, fieldChoices }));
|
|
473
|
+
server.tool("admin_accept_plant_type_proposal", "ADMIN WRITE: accept a pending proposal against a global plant type, in full or in part. Omitting fields accepts every field the proposal changed (status ACCEPTED); passing an explicit subset accepts only those (status PARTIAL, unless the subset happens to cover everything proposed). If a field's current value has moved on since the proposal was filed, accepting it fails the whole call with a 409 naming the stale fields (details.staleFields) — there is no override here; either handle that field by hand (e.g. admin_update_plant_type_fields) and retry with fields excluding it, or deny the proposal instead. Returns the type's full detail (post-accept) plus which fields actually changed. note is required and recorded on the change log. Requires an admin-scoped API key.", {
|
|
474
|
+
id: z.number().int().describe("Proposal id, from admin_list_plant_type_proposals."),
|
|
475
|
+
fields: z
|
|
476
|
+
.array(z.string())
|
|
477
|
+
.optional()
|
|
478
|
+
.describe("Subset of the proposal's changed fields to accept. Omit to accept all of them."),
|
|
479
|
+
note: z.string().min(1).describe("Required note, recorded on the change log."),
|
|
480
|
+
}, async ({ id, fields, note }) => apiSend("POST", `/api/v1/admin/plant-types/proposals/${id}/accept`, { fields, note }));
|
|
481
|
+
server.tool("admin_deny_plant_type_proposal", "ADMIN WRITE: deny a pending proposal against a global plant type. Never writes a change log entry on the type itself — a denied proposal is private to its author and the reviewer — and never touches the type's own field values. note is required. Requires an admin-scoped API key.", {
|
|
482
|
+
id: z.number().int().describe("Proposal id, from admin_list_plant_type_proposals."),
|
|
483
|
+
note: z.string().min(1).describe("Required note (recorded on the proposal, not the type's change log)."),
|
|
484
|
+
}, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/proposals/${id}/deny`, { note }));
|
|
485
|
+
server.tool("admin_deny_plant_type_proposals", "ADMIN WRITE: deny every PENDING proposal on one global plant type at once, optionally narrowed to one origin — useful for clearing a batch of migration-filed proposals in one call instead of denying them one at a time. A proposal that stopped being PENDING between the lookup and its turn is silently skipped rather than failing the whole call; every other failure aborts the batch. note is required, applied to every denial. Requires an admin-scoped API key.", {
|
|
486
|
+
typeId: z.number().int().describe("Global plant type id."),
|
|
487
|
+
origin: z.enum(["user", "migration"]).optional().describe("Only deny proposals filed by this origin. Omit to deny every PENDING proposal regardless of origin."),
|
|
488
|
+
note: z.string().min(1).describe("Required note, applied to every denial."),
|
|
489
|
+
}, async ({ typeId, origin, note }) => apiSend("POST", "/api/v1/admin/plant-types/proposals/deny", { typeId, origin, note }));
|
|
490
|
+
server.tool("admin_dismiss_duplicate_candidate", "ADMIN WRITE: dismiss an OPEN duplicate candidate as 'not actually a duplicate', without merging it. It never comes back — a previously dismissed pair is excluded from every future admin_rescan_duplicate_candidates run. note is required for consistency with every other admin write here, but is NOT stored anywhere: the duplicate-candidate row has no field to keep it in. Requires an admin-scoped API key.", {
|
|
491
|
+
id: z.number().int().describe("Duplicate candidate id, from admin_list_duplicate_candidates."),
|
|
492
|
+
note: z.string().min(1).describe("Required, but not persisted — see the tool description."),
|
|
493
|
+
}, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/duplicates/${id}/dismiss`, { note }));
|
|
494
|
+
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", {}));
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Calls `/api/v1/me` once at startup to decide whether this key carries the
|
|
498
|
+
* `admin` scope, and registers the admin catalog tools only when it does —
|
|
499
|
+
* so a non-admin key's session never even sees a tool it could not call
|
|
500
|
+
* (rather than registering it and letting every call fail with 403). A
|
|
501
|
+
* failed whoami call (network error, invalid key — `apiGet`'s own retry-free
|
|
502
|
+
* failure modes) is logged to stderr and registers no admin tools, the same
|
|
503
|
+
* as a confirmed non-admin key: better to under-register than to guess.
|
|
504
|
+
*/
|
|
505
|
+
async function registerAdminToolsIfPermitted() {
|
|
506
|
+
let res;
|
|
507
|
+
try {
|
|
508
|
+
res = await fetch(new URL(`${API_URL}/api/v1/me`), {
|
|
509
|
+
headers: { Authorization: `Bearer ${API_KEY}`, Accept: "application/json" },
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
console.error(`[plants-mcp] Could not reach ${API_URL}/api/v1/me to check for admin access: ${String(err)}. Admin tools not registered.`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (!res.ok) {
|
|
517
|
+
console.error(`[plants-mcp] /api/v1/me returned HTTP ${res.status}; admin tools not registered.`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
let body;
|
|
521
|
+
try {
|
|
522
|
+
body = await res.json();
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
console.error("[plants-mcp] /api/v1/me returned an unreadable response; admin tools not registered.");
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const scopes = body?.scopes;
|
|
529
|
+
if (Array.isArray(scopes) && scopes.includes("admin")) {
|
|
530
|
+
registerAdminTools();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
328
533
|
// --- Boot -------------------------------------------------------------------
|
|
329
534
|
async function main() {
|
|
535
|
+
await registerAdminToolsIfPermitted();
|
|
330
536
|
const transport = new StdioServerTransport();
|
|
331
537
|
await server.connect(transport);
|
|
332
538
|
console.error(`[plants-mcp] v${VERSION} connected. API base: ${API_URL}`);
|
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.0.0",
|
|
4
|
+
"description": "MCP server for the Sprig plant app: 56 tools to read a plant collection, browse the shared global plant type catalog, log care, edit a plant, add, propagate or merge plants, and manage a wishlist. Read-only by default; writes need a write-scoped API key. Two tools can delete a watering/fertilization event; nothing else deletes collection history. 17 more tools read, edit, merge and review the shared global plant type catalog for an admin-scoped key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "sebamomann <github@sebamomann.de>",
|