@sebamomann/plants-mcp 2.0.0 → 2.5.1
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 +99 -0
- package/README.md +160 -26
- package/dist/apiClient.js +103 -0
- package/dist/index.js +124 -89
- package/package.json +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,105 @@
|
|
|
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.1 — 2026-09-20
|
|
7
|
+
|
|
8
|
+
**Richer admin dashboard status.** Tool count unchanged (76 overall). A **patch** bump — the
|
|
9
|
+
`admin_get_dashboard_status` response gains four optional groups, nothing renamed or removed:
|
|
10
|
+
|
|
11
|
+
- `growth`, `community`, `trade`, `content` — accounts by status and care-active users, community
|
|
12
|
+
activity per week and open reports with age, aggregate deal/offer counts (never money or parties),
|
|
13
|
+
and photo/plant/analysis/notification totals. Each is independently `{ available: false }` when
|
|
14
|
+
its reads failed. See `docs/mcp-server.md`'s "Admin dashboard status".
|
|
15
|
+
|
|
16
|
+
## 2.5.0 — 2026-09-16
|
|
17
|
+
|
|
18
|
+
**Past problem diagnoses.** 76 tools registered overall (27 read, 31 write, 18 admin). A **minor**
|
|
19
|
+
bump — one new tool, nothing renamed or removed:
|
|
20
|
+
|
|
21
|
+
- `list_diagnoses` — a plant's guided problem diagnoses ("Problem eingrenzen"), newest first, with
|
|
22
|
+
the symptom, every question and the answer given, the ranked possible causes, the action the user
|
|
23
|
+
picked, and the resolution. Active and resolved runs alike, since a resolved run stays readable by
|
|
24
|
+
design. Stored i18n keys are resolved to plain English for the caller. Running the flow itself
|
|
25
|
+
stays UI-only — see `docs/mcp-server.md`.
|
|
26
|
+
|
|
27
|
+
## 2.4.0 — 2026-09-16
|
|
28
|
+
|
|
29
|
+
**Admin dashboard status.** 75 tools registered overall (26 read, 31 write, 18 admin). A **minor**
|
|
30
|
+
bump — one new tool, nothing renamed or removed:
|
|
31
|
+
|
|
32
|
+
- `admin_get_dashboard_status` — the same aggregate the app's own `/admin` page shows in one call:
|
|
33
|
+
every review queue's count and the age of its oldest item, catalog health (verified/unverified
|
|
34
|
+
split, types with no care values, orphan cultivars, types with no plants, low-completeness types,
|
|
35
|
+
unidentified plants), weekly activity (new plants, care events logged, contributions
|
|
36
|
+
submitted/decided), and operational facts (push-enabled users, pending email verifications,
|
|
37
|
+
active API keys, applied migration count — counts only, never a secret). No arguments. Sits
|
|
38
|
+
outside the catalog surface below (it isn't under `/api/v1/admin/plant-types/*`) — see
|
|
39
|
+
`docs/mcp-server.md`'s "Admin dashboard status".
|
|
40
|
+
|
|
41
|
+
## 2.3.0 — 2026-09-16
|
|
42
|
+
|
|
43
|
+
**Write parity: repotting, refills, hydro events, snoozing, lifecycle status, catalog
|
|
44
|
+
contributions, and notifications.** 74 tools registered overall (26 read, 31 write, 17 admin). A
|
|
45
|
+
**minor** bump — nine new tools, nothing renamed or removed:
|
|
46
|
+
|
|
47
|
+
- `record_repotting` — log a repotting (pot/soil/size), re-deriving the plant's soil and syncing
|
|
48
|
+
care mode on a self-watering pot change, the same as the app's own repotting form.
|
|
49
|
+
- `record_refill` — log a reservoir refill with fertilizer/percent, for a `reservoir`-mode plant.
|
|
50
|
+
- `record_hydro_event` — log a hydro-culture top-up or solution change.
|
|
51
|
+
- `snooze_care` — postpone a plant's next watering or fertilizing due date by a fixed duration (1,
|
|
52
|
+
3, 7, or 14 days).
|
|
53
|
+
- `set_plant_status` — set a plant's status to `LIVING`, `DEAD`, or `LOST` (deliberately narrower
|
|
54
|
+
than the app's full status range — see `docs/mcp-server.md`'s "Lifecycle status changes are a
|
|
55
|
+
safe subset").
|
|
56
|
+
- `set_plant_reference` — flip a plant between `LIVING` and `EXTERNAL` (reference-only);
|
|
57
|
+
reversible.
|
|
58
|
+
- `create_plant_type` — the user-facing counterpart to `admin_create_plant_type`: add a
|
|
59
|
+
species/cultivar the catalog is missing, always `UNVERIFIED`. Runs the plant-type picker's
|
|
60
|
+
duplicate check server-side (there's no client screen to have shown it first) — a likely
|
|
61
|
+
duplicate blocks creation (`{ created: false, duplicates }`) unless `confirmDespiteDuplicates:
|
|
62
|
+
true`. `propose_plant_type_change` stays on the roadmap; it needs its own design pass.
|
|
63
|
+
- `list_notifications` — the caller's notification centre, newest first, cursor-paginated.
|
|
64
|
+
Sale/trade notifications are always excluded, the same permanent exclusion
|
|
65
|
+
`list_recent_activity` applies.
|
|
66
|
+
- `mark_notifications_read` — mark every unread notification read.
|
|
67
|
+
|
|
68
|
+
## 2.2.0 — 2026-09-16
|
|
69
|
+
|
|
70
|
+
**Catalog creation, pots, and mode-agnostic vacation status.** 65 tools registered overall (25
|
|
71
|
+
read, 23 write, 17 admin). A **minor** bump — six new tools, nothing renamed or removed:
|
|
72
|
+
|
|
73
|
+
- `create_location` — create a location, optionally nested under an owned parent.
|
|
74
|
+
- `create_soil` — create a soil mix, optionally with components (each upserted by name).
|
|
75
|
+
- `create_fertilizer` — create a fertilizer, with an optional NPK label and base dose.
|
|
76
|
+
- `list_pots` — the caller's own pots plus the shared default pots, with reference links and
|
|
77
|
+
recorded sizes.
|
|
78
|
+
- `create_pot` — create a pot, optionally with reference links. Pot *sizes* still have no create
|
|
79
|
+
tool.
|
|
80
|
+
- `get_vacation_status` — whether the caller has a current or upcoming vacation in **either** mode
|
|
81
|
+
(`sitter` or `shift`), with just id/mode/date-range/active-state/plant-count — the mode-agnostic
|
|
82
|
+
counterpart `sitter_briefing` never provided (that tool only ever reports a `sitter`-mode
|
|
83
|
+
vacation's full detail).
|
|
84
|
+
|
|
85
|
+
The four create tools reuse the same cores the plant form's "create inline" pickers use
|
|
86
|
+
(`app/_lib/plants/resourceCreation.ts`), so a location/soil/fertilizer/pot created over MCP gets
|
|
87
|
+
identical validation and duplicate-name handling to one created in the app.
|
|
88
|
+
|
|
89
|
+
## 2.1.0 — 2026-09-16
|
|
90
|
+
|
|
91
|
+
**Watch a plant type.** 59 tools registered overall (23 read, 19 write, 17 admin). A **minor**
|
|
92
|
+
bump — three new tools, nothing renamed or removed:
|
|
93
|
+
|
|
94
|
+
- `list_watched_types` — the caller's "watch a type" subscriptions: type id, display/botanical
|
|
95
|
+
name, status.
|
|
96
|
+
- `watch_type` — subscribe to a global catalog type. Never creates a second watch for a type
|
|
97
|
+
already watched (`duplicate: true`), the same convention as `add_to_wishlist`.
|
|
98
|
+
- `unwatch_type` — remove a subscription. Not counted as a destructive delete, the same reasoning
|
|
99
|
+
as `remove_from_wishlist` — a watch is a personal subscription with no history to lose.
|
|
100
|
+
|
|
101
|
+
A watcher is notified **in-app only** (the Neuigkeiten centre) whenever a watched type's fields,
|
|
102
|
+
identity, names, verification status or approved photos change — there is no MCP read for
|
|
103
|
+
notifications yet (see `docs/mcp-server.md`'s roadmap).
|
|
104
|
+
|
|
6
105
|
## 2.0.0 — 2026-09-15
|
|
7
106
|
|
|
8
107
|
**Plant identity moves to a shared, community-maintained global catalog.** 56 tools registered
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ just ask:
|
|
|
10
10
|
|
|
11
11
|
## Quick start
|
|
12
12
|
|
|
13
|
-
**1. Get an API key.** In Sprig: **Account → API keys → Generate**. It is shown once — copy it
|
|
13
|
+
**1. Get an API key.** In Sprig: **Account → Settings → API keys → Generate**. It is shown once — copy it
|
|
14
14
|
then. Pick read-only unless you actually want the assistant logging care; you can change a key's
|
|
15
15
|
access level later without re-issuing it.
|
|
16
16
|
|
|
@@ -46,7 +46,7 @@ Requires **Node.js 20 or newer**. Nothing to install by hand: `npx` fetches the
|
|
|
46
46
|
time your client starts the server.
|
|
47
47
|
|
|
48
48
|
> 💡 The app has this guide built in, with your key and server address already filled in:
|
|
49
|
-
> **Account → API keys → How to connect an assistant**.
|
|
49
|
+
> **Account → Settings → API keys → How to connect an assistant**.
|
|
50
50
|
|
|
51
51
|
## What is Sprig?
|
|
52
52
|
|
|
@@ -60,18 +60,21 @@ needs a running Sprig instance and an API key from it. Start at the
|
|
|
60
60
|
|
|
61
61
|
## What an assistant can do
|
|
62
62
|
|
|
63
|
-
**
|
|
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
|
|
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
68
|
|
|
69
69
|
Reads cover plants, the shared global plant type catalog, watering and fertilization history, the
|
|
70
70
|
full care timeline, photo metadata, health entries, the derived care schedule, collection-wide
|
|
71
|
-
activity, the wishlist,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.
|
|
75
78
|
|
|
76
79
|
**Plant identity moved to a shared, community-maintained catalog in 2.0.0** — see **Breaking changes
|
|
77
80
|
in 2.0.0** below before upgrading a client that stored a `plantTypeId`.
|
|
@@ -106,11 +109,11 @@ healthy server, not a hang — press Ctrl-C.
|
|
|
106
109
|
|
|
107
110
|
The scope lives on the API key, is enforced by the app, and cannot be widened from this side.
|
|
108
111
|
|
|
109
|
-
- **`read`** — on every key. Gates all
|
|
110
|
-
- **`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.
|
|
111
114
|
- **`admin`** — only offered when the key's creator is themselves an admin, and only while they
|
|
112
115
|
still are one (the app re-checks this on every admin-tool call, not just at key creation). Gates
|
|
113
|
-
the
|
|
116
|
+
the 18 admin catalog tools — 7 read-only, 11 that write — see **Admin tools** below. `admin` alone
|
|
114
117
|
is enough for the write ones too (not also `write` — the three scopes are independent grants). An
|
|
115
118
|
admin's key is created with `read`+`write`+`admin` together, so it also works with every ordinary
|
|
116
119
|
tool.
|
|
@@ -132,9 +135,10 @@ Not oversights — deliberate limits:
|
|
|
132
135
|
stays UI-only — the blast radius of a misunderstood delete there is a multi-year history, and
|
|
133
136
|
retiring a plant is a *status* change instead. `delete_watering_event` and
|
|
134
137
|
`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`
|
|
136
|
-
|
|
137
|
-
lose, trivially re-added with `add_to_wishlist`
|
|
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.
|
|
138
142
|
- **No sales or trades.** Those records name a second person who never consented to your API key.
|
|
139
143
|
- **No share-link creation.** Minting a public URL for your collection is a decision for the UI.
|
|
140
144
|
- **No cross-user access.** Ownership comes from the key; a tool has no way to even express
|
|
@@ -169,7 +173,7 @@ instead of a raw value — see **Breaking changes in 2.0.0** below.
|
|
|
169
173
|
|
|
170
174
|
### Per-plant history
|
|
171
175
|
|
|
172
|
-
All
|
|
176
|
+
All six take the same arguments: `id` **(required)**, `limit` (1–500), `offset`. Newest first.
|
|
173
177
|
|
|
174
178
|
| Tool | Endpoint | Returns |
|
|
175
179
|
|---|---|---|
|
|
@@ -178,6 +182,7 @@ All five take the same arguments: `id` **(required)**, `limit` (1–500), `offse
|
|
|
178
182
|
| `list_care_events` | `GET /api/v1/plants/:id/events` | Combined timeline — watering, fertilization, refill, hydro, potting, snoozes — each tagged with a `kind` |
|
|
179
183
|
| `list_photos` | `GET /api/v1/plants/:id/photos` | Photo **metadata** (urls, `takenAt`). Not the images themselves. |
|
|
180
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. |
|
|
181
186
|
|
|
182
187
|
### Care schedule
|
|
183
188
|
|
|
@@ -229,6 +234,7 @@ page.
|
|
|
229
234
|
| Tool | Endpoint | Arguments |
|
|
230
235
|
|---|---|---|
|
|
231
236
|
| `sitter_briefing` | `GET /api/v1/vacation/sitter-briefing` | `locationId` (optional) |
|
|
237
|
+
| `get_vacation_status` | `GET /api/v1/vacation/status` | none |
|
|
232
238
|
|
|
233
239
|
Printable care sheet for the caller's current or next `sitter`-mode vacation: per-plant sitter
|
|
234
240
|
instructions, watering notes, cycle and next-due state, and the day-by-day watering schedule for
|
|
@@ -236,6 +242,11 @@ the trip. Returns `{ hasVacation: false }` when there's no upcoming or running s
|
|
|
236
242
|
this tool never creates or modifies one. `locationId` narrows the plant list to one location; the
|
|
237
243
|
schedule always covers the whole vacation.
|
|
238
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
|
+
|
|
239
250
|
### Wishlist
|
|
240
251
|
|
|
241
252
|
| Tool | Endpoint | Arguments |
|
|
@@ -247,6 +258,31 @@ catalog type, with its display/botanical name and `typeStatus`) or a free-text `
|
|
|
247
258
|
a species not yet in the catalog, a `notes` array (dated, e.g. price or where to get it), and
|
|
248
259
|
`ownedLivingCount` — how many `LIVING` plants of that type the caller already has.
|
|
249
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
|
+
|
|
250
286
|
### Catalogs
|
|
251
287
|
|
|
252
288
|
For resolving the ids the filters take.
|
|
@@ -259,6 +295,10 @@ For resolving the ids the filters take.
|
|
|
259
295
|
| `get_plant_type` | `GET /api/v1/plant-types/:id` | `id` **(required)** — one type's fact sheet |
|
|
260
296
|
| `list_soils` | `GET /api/v1/soils` | none |
|
|
261
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.
|
|
262
302
|
|
|
263
303
|
**`list_plant_types` is the shared, community-maintained global catalog** — the same for every
|
|
264
304
|
user, not a per-user list. `list_plant_types` and `search_plant_types` are the same endpoint and
|
|
@@ -277,11 +317,14 @@ to do with the stub itself. A `REJECTED` id behaves the same way when its outcom
|
|
|
277
317
|
(`mergedIntoId` set); otherwise it resolves to the type's own now-`REJECTED` row, since there's no
|
|
278
318
|
survivor to point to.
|
|
279
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.
|
|
322
|
+
|
|
280
323
|
---
|
|
281
324
|
|
|
282
325
|
## Write tools
|
|
283
326
|
|
|
284
|
-
**All
|
|
327
|
+
**All 31 ordinary write tools require a `write`-scoped key**; a read-only key gets HTTP 403. Every
|
|
285
328
|
description starts with `WRITE:` so a model cannot mistake one for a read.
|
|
286
329
|
|
|
287
330
|
### Care logging
|
|
@@ -290,6 +333,10 @@ description starts with `WRITE:` so a model cannot mistake one for a read.
|
|
|
290
333
|
|---|---|---|
|
|
291
334
|
| `record_watering` | `POST /api/v1/care/watering` | `plantIds` **(required,** 1–200**)**, `wateredAt` (`YYYY-MM-DD`, defaults to now, no future dates) |
|
|
292
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` |
|
|
293
340
|
|
|
294
341
|
`record_watering` mirrors a one-click watering in the app: reservoir and hydro plants are recorded
|
|
295
342
|
as a refill / top-up automatically, and plants configured to fertilize with watering also get a
|
|
@@ -297,7 +344,23 @@ fertilization logged.
|
|
|
297
344
|
|
|
298
345
|
`record_fertilization` defaults `fertilizerId` and `fertilizerPercent` to **each plant's own
|
|
299
346
|
settings** when omitted, so a bulk call across differently-configured plants still does the right
|
|
300
|
-
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.
|
|
301
364
|
|
|
302
365
|
### Plant edits
|
|
303
366
|
|
|
@@ -349,6 +412,8 @@ don't exist yet, `propagate_plant` creates them *and* sets the link in one call.
|
|
|
349
412
|
| `propagate_plant` | `POST /api/v1/plants/:id/propagations` | `plantId` **(required)**, `count` (1–10, default 1) |
|
|
350
413
|
| `merge_plants` | `POST /api/v1/plants/merge` | `sourcePlantIds` **(required,** 1–200**)**, plus **exactly one of** `survivorPlantId` or `createNewFrom` |
|
|
351
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)** |
|
|
352
417
|
|
|
353
418
|
`create_plant`'s `plantTypeId` is now **optional**: pass an existing global type id (from
|
|
354
419
|
`list_plant_types`/`search_plant_types`) when the species is known, or omit it (or pass `null`) to
|
|
@@ -370,6 +435,21 @@ call. All sources must be `LIVING`, and `survivorPlantId` must not also appear i
|
|
|
370
435
|
Unmerging leaves the survivor in place. If the merge created it via `createNewFrom` and you no
|
|
371
436
|
longer want it, deleting that plant is a UI action.
|
|
372
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
|
+
|
|
373
453
|
### Health entries
|
|
374
454
|
|
|
375
455
|
| Tool | Endpoint | Arguments |
|
|
@@ -407,6 +487,52 @@ it is a configuration contradiction, fixed by editing the plant rather than hidd
|
|
|
407
487
|
`add_to_wishlist` never creates a second wish for a type the caller already has — it returns the
|
|
408
488
|
existing one instead (`duplicate: true`), rather than erroring or silently doubling it up.
|
|
409
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
|
+
|
|
410
536
|
### Deleting events
|
|
411
537
|
|
|
412
538
|
| Tool | Endpoint | Arguments |
|
|
@@ -438,13 +564,13 @@ the response, not just the HTTP status.
|
|
|
438
564
|
|
|
439
565
|
## Admin tools (admin-scoped keys only)
|
|
440
566
|
|
|
441
|
-
**
|
|
442
|
-
species/cultivar entry, one catalog for the whole app rather than per-user
|
|
443
|
-
scope (see **Permissions & scopes** above),
|
|
444
|
-
server calls `whoami` once at startup and only
|
|
445
|
-
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`.
|
|
446
572
|
|
|
447
|
-
### Read-only (
|
|
573
|
+
### Read-only (7)
|
|
448
574
|
|
|
449
575
|
| Tool | Endpoint | Arguments |
|
|
450
576
|
|---|---|---|
|
|
@@ -454,6 +580,7 @@ includes `admin`.
|
|
|
454
580
|
| `admin_list_duplicate_candidates` | `GET /api/v1/admin/plant-types/duplicates` | `status` (`OPEN`\|`DISMISSED`\|`MERGED`, defaults to `OPEN`), `limit` (1–200), `offset` |
|
|
455
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` |
|
|
456
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, operational facts (counts only, never a secret), plus optional growth/community/trade/content aggregates (no money, no user details) |
|
|
457
584
|
|
|
458
585
|
`admin_list_plant_types` returns compact rows only (no full field values) — call `admin_get_plant_type`
|
|
459
586
|
for one type's complete fact sheet. Its `search` is fuzzy, the same matcher the admin catalog list
|
|
@@ -469,6 +596,13 @@ write) can always be checked against real bounds/enum values first.
|
|
|
469
596
|
changed field `stale` when the type's value has moved on since the proposal was filed — the exact
|
|
470
597
|
check a human reviewer's accept button re-runs, so an assistant sees the same warning.
|
|
471
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 (including the growth, community, trade and content blocks, each optional and individually `{ available: false }` if its read failed; trade is aggregate counts only, never a price or a party), 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
|
+
|
|
472
606
|
### Write (11)
|
|
473
607
|
|
|
474
608
|
Every description starts with `ADMIN WRITE:`, and **every one of these requires a non-empty `note`**
|
|
@@ -634,6 +768,6 @@ spec for the tool surface is `docs/mcp-server.md` at the repo root.
|
|
|
634
768
|
variable, so it never lands in the tool arguments a model can see or echo. Treat it like a
|
|
635
769
|
password.
|
|
636
770
|
- Prefer a **read-only key** unless you specifically want an assistant logging care.
|
|
637
|
-
- Revoke a key any time from **Account → API keys**; requests with it start returning 401
|
|
771
|
+
- Revoke a key any time from **Account → Settings → API keys**; requests with it start returning 401
|
|
638
772
|
immediately.
|
|
639
773
|
- All data is scoped to the key's owner. Another user's data returns 404.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared HTTP proxy every tool in `index.ts` is built on: `apiGet` for reads,
|
|
3
|
+
* `apiSend` for writes. Pulled into its own module so it can be unit-tested in
|
|
4
|
+
* isolation — `index.ts` has top-level side effects (env var validation that calls
|
|
5
|
+
* `process.exit`, `McpServer` construction, ~76 tool registrations, stdio connect)
|
|
6
|
+
* that make it unsafe to import directly in a test.
|
|
7
|
+
*
|
|
8
|
+
* Both functions take the API base URL and key as a config object rather than
|
|
9
|
+
* reading them from `process.env` themselves, so a test can point them at a mocked
|
|
10
|
+
* `fetch` with fixture values instead of needing real environment variables.
|
|
11
|
+
*/
|
|
12
|
+
export function createApiClient({ apiUrl, apiKey }) {
|
|
13
|
+
async function apiGet(path, query = {}) {
|
|
14
|
+
const url = new URL(`${apiUrl}${path}`);
|
|
15
|
+
for (const [key, value] of Object.entries(query)) {
|
|
16
|
+
if (value !== undefined && value !== "")
|
|
17
|
+
url.searchParams.set(key, String(value));
|
|
18
|
+
}
|
|
19
|
+
let res;
|
|
20
|
+
try {
|
|
21
|
+
res = await fetch(url, {
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${apiKey}`,
|
|
24
|
+
Accept: "application/json",
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text: `Network error calling ${url.pathname}: ${String(err)}` }],
|
|
31
|
+
isError: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const body = await res.text();
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
return {
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: "text",
|
|
40
|
+
text: `Request to ${url.pathname} failed (HTTP ${res.status}): ${body || res.statusText}`,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
isError: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Pretty-print JSON when possible, otherwise return raw text.
|
|
47
|
+
let text = body;
|
|
48
|
+
try {
|
|
49
|
+
text = JSON.stringify(JSON.parse(body), null, 2);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// leave as-is
|
|
53
|
+
}
|
|
54
|
+
return { content: [{ type: "text", text }] };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Sends a JSON body to a mutating endpoint. Requires an API key with the
|
|
58
|
+
* `write` scope — a read-only key gets a 403 surfaced back to the model verbatim
|
|
59
|
+
* so it can tell the user their key can't write. `DELETE` sends no body.
|
|
60
|
+
*/
|
|
61
|
+
async function apiSend(method, path, body) {
|
|
62
|
+
const url = new URL(`${apiUrl}${path}`);
|
|
63
|
+
let res;
|
|
64
|
+
try {
|
|
65
|
+
res = await fetch(url, {
|
|
66
|
+
method,
|
|
67
|
+
headers: {
|
|
68
|
+
Authorization: `Bearer ${apiKey}`,
|
|
69
|
+
Accept: "application/json",
|
|
70
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
71
|
+
},
|
|
72
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: "text", text: `Network error calling ${url.pathname}: ${String(err)}` }],
|
|
78
|
+
isError: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const text = await res.text();
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: "text",
|
|
87
|
+
text: `Request to ${url.pathname} failed (HTTP ${res.status}): ${text || res.statusText}`,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
isError: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
let pretty = text;
|
|
94
|
+
try {
|
|
95
|
+
pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// leave as-is
|
|
99
|
+
}
|
|
100
|
+
return { content: [{ type: "text", text: pretty }] };
|
|
101
|
+
}
|
|
102
|
+
return { apiGet, apiSend };
|
|
103
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { createApiClient } from "./apiClient.js";
|
|
6
7
|
/**
|
|
7
8
|
* MCP server for the Sprig plant app: reads the collection, and — with a
|
|
8
9
|
* write-scoped key — logs care, edits a plant, and deletes a watering or
|
|
@@ -31,95 +32,7 @@ if (!API_KEY) {
|
|
|
31
32
|
console.error("[plants-mcp] Missing PLANT_API_KEY environment variable.");
|
|
32
33
|
process.exit(1);
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
-
const url = new URL(`${API_URL}${path}`);
|
|
36
|
-
for (const [key, value] of Object.entries(query)) {
|
|
37
|
-
if (value !== undefined && value !== "")
|
|
38
|
-
url.searchParams.set(key, String(value));
|
|
39
|
-
}
|
|
40
|
-
let res;
|
|
41
|
-
try {
|
|
42
|
-
res = await fetch(url, {
|
|
43
|
-
headers: {
|
|
44
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
45
|
-
Accept: "application/json",
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
catch (err) {
|
|
50
|
-
return {
|
|
51
|
-
content: [{ type: "text", text: `Network error calling ${url.pathname}: ${String(err)}` }],
|
|
52
|
-
isError: true,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
const body = await res.text();
|
|
56
|
-
if (!res.ok) {
|
|
57
|
-
return {
|
|
58
|
-
content: [
|
|
59
|
-
{
|
|
60
|
-
type: "text",
|
|
61
|
-
text: `Request to ${url.pathname} failed (HTTP ${res.status}): ${body || res.statusText}`,
|
|
62
|
-
},
|
|
63
|
-
],
|
|
64
|
-
isError: true,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
// Pretty-print JSON when possible, otherwise return raw text.
|
|
68
|
-
let text = body;
|
|
69
|
-
try {
|
|
70
|
-
text = JSON.stringify(JSON.parse(body), null, 2);
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
// leave as-is
|
|
74
|
-
}
|
|
75
|
-
return { content: [{ type: "text", text }] };
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Sends a JSON body to a mutating endpoint. Requires an API key with the
|
|
79
|
-
* `write` scope — a read-only key gets a 403 surfaced back to the model verbatim
|
|
80
|
-
* so it can tell the user their key can't write. `DELETE` sends no body.
|
|
81
|
-
*/
|
|
82
|
-
async function apiSend(method, path, body) {
|
|
83
|
-
const url = new URL(`${API_URL}${path}`);
|
|
84
|
-
let res;
|
|
85
|
-
try {
|
|
86
|
-
res = await fetch(url, {
|
|
87
|
-
method,
|
|
88
|
-
headers: {
|
|
89
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
90
|
-
Accept: "application/json",
|
|
91
|
-
...(body ? { "Content-Type": "application/json" } : {}),
|
|
92
|
-
},
|
|
93
|
-
...(body ? { body: JSON.stringify(body) } : {}),
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
catch (err) {
|
|
97
|
-
return {
|
|
98
|
-
content: [{ type: "text", text: `Network error calling ${url.pathname}: ${String(err)}` }],
|
|
99
|
-
isError: true,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
const text = await res.text();
|
|
103
|
-
if (!res.ok) {
|
|
104
|
-
return {
|
|
105
|
-
content: [
|
|
106
|
-
{
|
|
107
|
-
type: "text",
|
|
108
|
-
text: `Request to ${url.pathname} failed (HTTP ${res.status}): ${text || res.statusText}`,
|
|
109
|
-
},
|
|
110
|
-
],
|
|
111
|
-
isError: true,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
let pretty = text;
|
|
115
|
-
try {
|
|
116
|
-
pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
// leave as-is
|
|
120
|
-
}
|
|
121
|
-
return { content: [{ type: "text", text: pretty }] };
|
|
122
|
-
}
|
|
35
|
+
const { apiGet, apiSend } = createApiClient({ apiUrl: API_URL, apiKey: API_KEY });
|
|
123
36
|
const server = new McpServer({ name: "plants-mcp", version: VERSION });
|
|
124
37
|
// --- Identity ---------------------------------------------------------------
|
|
125
38
|
server.tool("whoami", "Return the authenticated user for the configured API key.", async () => apiGet("/api/v1/me"));
|
|
@@ -152,6 +65,7 @@ server.tool("list_fertilization_events", "List fertilization events for a plant,
|
|
|
152
65
|
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
66
|
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
67
|
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));
|
|
68
|
+
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
69
|
// --- Care schedule (calendar) ----------------------------------------------
|
|
156
70
|
/**
|
|
157
71
|
* Derived schedule state, computed server-side from the same helpers the app's
|
|
@@ -206,6 +120,7 @@ server.tool("collection_stats", "Counts and totals across the whole collection:
|
|
|
206
120
|
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
121
|
locationId: z.number().int().optional().describe("Only include plants in this location (id from list_locations). Omit for every plant on the vacation."),
|
|
208
122
|
}, async (args) => apiGet("/api/v1/vacation/sitter-briefing", args));
|
|
123
|
+
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
124
|
// --- Care logging (writes) --------------------------------------------------
|
|
210
125
|
/**
|
|
211
126
|
* Mutating tools. These require an API key with the `write` scope; a read-only
|
|
@@ -234,6 +149,38 @@ server.tool("record_fertilization", "WRITE: log a fertilization for one or more
|
|
|
234
149
|
.describe("Strength as a percentage of the base dose; defaults to each plant's own."),
|
|
235
150
|
fertilizedAt: optionalPlantDate(""),
|
|
236
151
|
}, async (args) => apiSend("POST", "/api/v1/care/fertilization", { ...args }));
|
|
152
|
+
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.", {
|
|
153
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
154
|
+
potId: z.number().int().positive().describe("Pot id, from list_pots (the caller's own pots, plus shared defaults)."),
|
|
155
|
+
potSizeId: z.number().int().positive().optional().describe("One of the pot's own recorded sizes, from that pot's row in list_pots."),
|
|
156
|
+
soilId: z.number().int().positive().optional().describe("Soil mix id, from list_soils."),
|
|
157
|
+
pottedAt: optionalPlantDate("Defaults to today, or to the plant's acquiredAt for its first-ever potting event."),
|
|
158
|
+
hasDrainage: z.boolean().optional().describe("Whether the pot has a drainage hole."),
|
|
159
|
+
hasClimbingAid: z.boolean().optional().describe("Whether a climbing aid (moss pole, trellis) was added."),
|
|
160
|
+
notes: z.string().max(2000).optional().describe("Free-text notes about this repotting."),
|
|
161
|
+
}, async ({ plantId, ...body }) => apiSend("POST", `/api/v1/plants/${plantId}/repotting`, body));
|
|
162
|
+
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.", {
|
|
163
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
164
|
+
fertilizerId: z.number().int().positive().optional().describe("Override fertilizer; defaults to the plant's own."),
|
|
165
|
+
fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose; defaults to the plant's own."),
|
|
166
|
+
refilledAt: optionalPlantDate(""),
|
|
167
|
+
}, async ({ plantId, ...body }) => apiSend("POST", `/api/v1/plants/${plantId}/refill`, body));
|
|
168
|
+
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.", {
|
|
169
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
170
|
+
kind: z.enum(["topup", "change"]).optional().describe("'topup' (default) or 'change' (full solution change)."),
|
|
171
|
+
waterLevel: z.number().int().min(0).max(100).optional().describe("Reservoir fill level as a percentage."),
|
|
172
|
+
fertilizerId: z.number().int().positive().optional().describe("Override fertilizer; defaults to the plant's own."),
|
|
173
|
+
fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose; defaults to the plant's own."),
|
|
174
|
+
recordedAt: optionalPlantDate(""),
|
|
175
|
+
notes: z.string().max(2000).optional().describe("Free-text notes."),
|
|
176
|
+
}, async ({ plantId, ...body }) => apiSend("POST", `/api/v1/plants/${plantId}/hydro`, body));
|
|
177
|
+
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.", {
|
|
178
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
179
|
+
careType: z.enum(["water", "fert"]).describe("Which schedule to postpone."),
|
|
180
|
+
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."),
|
|
181
|
+
originalDueAt: z.string().describe("The due date being pushed out, e.g. from list_due_care's nextDue for this plant/careType."),
|
|
182
|
+
note: z.string().max(160).optional().describe("Free-text reason, e.g. 'away for the week'."),
|
|
183
|
+
}, async (args) => apiSend("POST", "/api/v1/care/snooze", { ...args }));
|
|
237
184
|
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
185
|
plantId: z.number().int().positive().describe("Plant id."),
|
|
239
186
|
wateringMode: z.enum(["scheduled", "reservoir", "hydro"]).optional().describe("How this plant is watered: on a schedule, from a reservoir, or in water/hydro culture."),
|
|
@@ -326,6 +273,19 @@ server.tool("merge_plants", "WRITE: merge several living plants into one — for
|
|
|
326
273
|
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.", {
|
|
327
274
|
plantId: z.number().int().positive().describe("The MERGED plant to restore."),
|
|
328
275
|
}, async ({ plantId }) => apiSend("POST", `/api/v1/plants/${plantId}/unmerge`));
|
|
276
|
+
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.", {
|
|
277
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
278
|
+
status: z.enum(["LIVING", "DEAD", "LOST"]).describe("New status."),
|
|
279
|
+
deadDeclaredAt: optionalPlantDate("Only applies when status is DEAD."),
|
|
280
|
+
deathCause: z
|
|
281
|
+
.enum(["overwatering", "underwatering", "pests", "disease", "rootRot", "light", "cold", "transplantShock", "oldAge", "unknown", "other"])
|
|
282
|
+
.optional()
|
|
283
|
+
.describe("Only applies when status is DEAD."),
|
|
284
|
+
}, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}/status`, body));
|
|
285
|
+
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.", {
|
|
286
|
+
plantId: z.number().int().positive().describe("Plant id."),
|
|
287
|
+
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)."),
|
|
288
|
+
}, async ({ plantId, reference }) => apiSend("PATCH", `/api/v1/plants/${plantId}/reference`, { reference }));
|
|
329
289
|
// --- Wishlist ----------------------------------------------------------
|
|
330
290
|
/**
|
|
331
291
|
* The caller's personal wishlist (R2-15) — plants they want but don't own
|
|
@@ -346,6 +306,34 @@ server.tool("add_wishlist_note", "WRITE: add a dated note to a wish — price, w
|
|
|
346
306
|
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
307
|
wishId: z.number().int().positive().describe("Wish id, from list_wishlist."),
|
|
348
308
|
}, async ({ wishId }) => apiSend("DELETE", `/api/v1/wishlist/${wishId}`));
|
|
309
|
+
// --- Watched plant types -------------------------------------------------
|
|
310
|
+
/**
|
|
311
|
+
* The caller's "watch a type" subscriptions — the eye toggle on a catalog
|
|
312
|
+
* row or type page. A watched type notifies its watcher (in-app, via the
|
|
313
|
+
* Neuigkeiten centre — see list_notifications below) whenever an admin
|
|
314
|
+
* edits its fields, identity or names, verifies it, merges it into another
|
|
315
|
+
* type, or a new photo is approved for it.
|
|
316
|
+
*/
|
|
317
|
+
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"));
|
|
318
|
+
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.", {
|
|
319
|
+
plantTypeId: z.number().int().positive().describe("An existing global plant type id."),
|
|
320
|
+
}, async ({ plantTypeId }) => apiSend("POST", "/api/v1/plant-type-watches", { plantTypeId }));
|
|
321
|
+
server.tool("unwatch_type", "WRITE: stop watching a plant type. Requires a write-scoped API key.", {
|
|
322
|
+
plantTypeId: z.number().int().positive().describe("The watched type's id, from list_watched_types."),
|
|
323
|
+
}, async ({ plantTypeId }) => apiSend("DELETE", `/api/v1/plant-type-watches/${plantTypeId}`));
|
|
324
|
+
// --- Notifications -----------------------------------------------------------
|
|
325
|
+
/**
|
|
326
|
+
* The in-app notification centre (`/notifications`): catalog decisions on a
|
|
327
|
+
* watched type, a watched/created type's photo or proposal outcome, and a
|
|
328
|
+
* collection share — one durable, always-on channel alongside push/email.
|
|
329
|
+
* Sale/trade notifications are always excluded here, the same permanent
|
|
330
|
+
* exclusion `list_recent_activity` applies — each names a second person who
|
|
331
|
+
* never consented to the owner's API key.
|
|
332
|
+
*/
|
|
333
|
+
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.", {
|
|
334
|
+
cursor: z.number().int().optional().describe("Pass the previous call's nextCursor to continue; omit for the first page."),
|
|
335
|
+
}, async ({ cursor }) => apiGet("/api/v1/notifications", { cursor }));
|
|
336
|
+
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"));
|
|
349
337
|
// --- Catalogs (for resolving filter ids) -----------------------------------
|
|
350
338
|
server.tool("list_locations", "List the user's locations.", async () => apiGet("/api/v1/locations"));
|
|
351
339
|
/**
|
|
@@ -366,8 +354,54 @@ const publicPlantTypeListArgs = {
|
|
|
366
354
|
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
355
|
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
356
|
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}`));
|
|
357
|
+
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.", {
|
|
358
|
+
genus: z.string().max(100).optional().describe("Genus. Required together with species/cultivar identity, or provide commonName instead."),
|
|
359
|
+
species: z.string().max(100).optional().describe("Species epithet."),
|
|
360
|
+
cultivar: z.string().max(100).optional().describe("Cultivar name."),
|
|
361
|
+
commonName: z.string().max(100).optional().describe("A common name, in the caller's own locale. Required if genus is omitted."),
|
|
362
|
+
values: z
|
|
363
|
+
.object({
|
|
364
|
+
sunRequirement: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
365
|
+
waterRequirement: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
366
|
+
suggestedWateringDaysSummer: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
367
|
+
suggestedWateringDaysWinter: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
368
|
+
toxicToPets: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
369
|
+
difficulty: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
370
|
+
})
|
|
371
|
+
.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."),
|
|
372
|
+
note: z.string().max(500).optional().describe("Optional context for the catalog change log. Defaults to a generic 'created via MCP' note."),
|
|
373
|
+
confirmDespiteDuplicates: z.boolean().optional().describe("Set true to create anyway after reviewing the duplicates a prior call returned."),
|
|
374
|
+
}, async (body) => apiSend("POST", "/api/v1/plant-types", body));
|
|
375
|
+
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.", {
|
|
376
|
+
name: z.string().min(1).max(100).describe("Location name."),
|
|
377
|
+
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."),
|
|
378
|
+
}, async ({ name, parentId }) => apiSend("POST", "/api/v1/locations", { name, parentId }));
|
|
369
379
|
server.tool("list_soils", "List the user's soils.", async () => apiGet("/api/v1/soils"));
|
|
380
|
+
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.", {
|
|
381
|
+
name: z.string().min(1).max(100).describe("Soil name."),
|
|
382
|
+
notes: z.string().max(2000).optional().describe("Free-text notes."),
|
|
383
|
+
items: z
|
|
384
|
+
.array(z.object({ componentName: z.string().min(1).max(100), parts: z.number().int().min(1).max(100) }))
|
|
385
|
+
.optional()
|
|
386
|
+
.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."),
|
|
387
|
+
}, async ({ name, notes, items }) => apiSend("POST", "/api/v1/soils", { name, notes, items }));
|
|
370
388
|
server.tool("list_fertilizers", "List the user's fertilizers.", async () => apiGet("/api/v1/fertilizers"));
|
|
389
|
+
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.", {
|
|
390
|
+
name: z.string().min(1).max(100).describe("Fertilizer name."),
|
|
391
|
+
npk: z.string().max(30).optional().describe("NPK ratio label, e.g. '7-3-6'."),
|
|
392
|
+
baseDosePerLiterMl: z.number().min(0).max(100).optional().describe("Base dose in ml per liter of water."),
|
|
393
|
+
}, async ({ name, npk, baseDosePerLiterMl }) => apiSend("POST", "/api/v1/fertilizers", { name, npk, baseDosePerLiterMl }));
|
|
394
|
+
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"));
|
|
395
|
+
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.", {
|
|
396
|
+
name: z.string().min(1).max(100).describe("Pot name."),
|
|
397
|
+
material: z.string().max(50).optional().describe("Material, e.g. 'Terracotta'."),
|
|
398
|
+
isSelfWatering: z.boolean().optional().describe("Whether the pot is self-watering."),
|
|
399
|
+
notes: z.string().max(2000).optional().describe("Free-text notes."),
|
|
400
|
+
links: z
|
|
401
|
+
.array(z.object({ url: z.string().max(2000), label: z.string().max(80).optional() }))
|
|
402
|
+
.optional()
|
|
403
|
+
.describe("Reference links, e.g. a shop listing. Order follows the array."),
|
|
404
|
+
}, async ({ name, material, isSelfWatering, notes, links }) => apiSend("POST", "/api/v1/pots", { name, material, isSelfWatering, notes, links }));
|
|
371
405
|
// --- Admin: global plant type catalog (admin-scoped keys only) -------------
|
|
372
406
|
/**
|
|
373
407
|
* Tools over the shared, community-maintained plant type catalog — finding
|
|
@@ -492,6 +526,7 @@ function registerAdminTools() {
|
|
|
492
526
|
note: z.string().min(1).describe("Required, but not persisted — see the tool description."),
|
|
493
527
|
}, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/duplicates/${id}/dismiss`, { note }));
|
|
494
528
|
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", {}));
|
|
529
|
+
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). Four more optional groups follow, each independently `{ available: false }` when its reads failed: growth (accounts by status, care-active users over 7/30 days, active users with no plant, users with push or an API key — sign-ups per week are not tracked), community (posts/comments/reactions per week, open reports with age, muted users, follows, active share links), trade (aggregate deal and offer counts by status, sold vs gifted vs traded, wishlist size — never prices, money or parties), and content (photos and 30-day growth, living vs archived plants, plant analyses, AI plausibility checks, notifications sent vs read over 30 days). 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"));
|
|
495
530
|
}
|
|
496
531
|
/**
|
|
497
532
|
* 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": "2.
|
|
4
|
-
"description": "MCP server for the Sprig plant app:
|
|
3
|
+
"version": "2.5.1",
|
|
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 (queues, catalog health, activity, growth, community, trade and content aggregates) for an admin-scoped key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "sebamomann <github@sebamomann.de>",
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
"dev": "tsx src/index.ts",
|
|
45
45
|
"typecheck": "tsc --noEmit",
|
|
46
46
|
"check:docs": "node scripts/check-tool-docs.mjs",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest",
|
|
47
49
|
"prepublishOnly": "npm run build"
|
|
48
50
|
},
|
|
49
51
|
"dependencies": {
|
|
@@ -52,6 +54,7 @@
|
|
|
52
54
|
},
|
|
53
55
|
"devDependencies": {
|
|
54
56
|
"@types/node": "^26.1.1",
|
|
55
|
-
"typescript": "^6.0.3"
|
|
57
|
+
"typescript": "^6.0.3",
|
|
58
|
+
"vitest": "^4.1.10"
|
|
56
59
|
}
|
|
57
60
|
}
|