@sebamomann/plants-mcp 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,38 @@
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
+ ## 1.2.0 — 2026-08-03
7
+
8
+ **`update_plant_care` gained two new optional arguments: `wateringMode` and `fertilizingMode`.**
9
+ Found missing in practice: a plant with `wateringMode: "hydro"` but a real potting-mix soil
10
+ assigned trips the `wateringCycleMismatch` recommendation, whose own description says it is "fixed
11
+ by editing the plant" — but neither plant-edit tool could change the mode field actually causing
12
+ the mismatch. Now it can:
13
+
14
+ - `wateringMode` (`scheduled` | `reservoir` | `hydro`) and `fertilizingMode` (`scheduled` |
15
+ `with_watering`) are optional fields on `update_plant_care`. `careMode`, the legacy mirror of
16
+ `wateringMode`, updates itself.
17
+ - If a mode field is set in the **same call** as the fields it gates (e.g. a watering cycle),
18
+ gating now runs against the *new* mode, not the plant's stored one — switching to `"reservoir"`
19
+ while also passing `wateringFrequencySummerDays` drops that field instead of saving a cycle the
20
+ new mode has no use for.
21
+
22
+ No tool added or removed, so this is a minor bump for the new arguments, not a new tool.
23
+
24
+ ## 1.1.0 — 2026-08-03
25
+
26
+ **27 tools: 17 read, 10 write.** Four new tools, all requiring a `write`-scoped key:
27
+
28
+ - **`update_plant_care`** — edit watering days/labels, sun and water requirement, fertilizing
29
+ cycles and percent, and the two care-notes fields. Fields that don't apply to the plant's current
30
+ watering/fertilizing mode (e.g. a watering cycle on a reservoir plant) are silently ignored, same
31
+ as the in-app edit form.
32
+ - **`update_plant`** — edit location, soil, fertilizer, quantity, notes, and sitter instructions.
33
+ `locationId`/`soilId`/`fertilizerId` are ownership-checked before the connect.
34
+ - **`delete_watering_event`** and **`delete_fertilization_event`** — the server's first destructive
35
+ tools. Everything else (plants, photos, catalog entries) is still UI-only; these two exist because
36
+ a mis-logged event has no reversible alternative. Neither can be undone.
37
+
6
38
  ## 1.0.0 — 2026-07-26
7
39
 
8
40
  **The tool surface is now stable.** This is the point of the major bump: the 23 tools, their
package/README.md CHANGED
@@ -1,15 +1,22 @@
1
1
  # @sebamomann/plants-mcp
2
2
 
3
- **Let an AI assistant look after your houseplants.** This is an
4
- [MCP](https://modelcontextprotocol.io) server that connects Claude — or any MCP client — to your
5
- plant collection in **Sprig**, so you can just ask:
3
+ **Let an AI assistant look after your houseplants.** An [MCP](https://modelcontextprotocol.io)
4
+ server that connects Claude — or any MCP client — to your plant collection in **Sprig**, so you can
5
+ just ask:
6
6
 
7
7
  > *"What needs watering today?"*
8
8
  > *"Which plants am I behind on?"*
9
9
  > *"Log that I watered everything on the windowsill."*
10
10
 
11
+ ## Quick start
12
+
13
+ **1. Get an API key.** In Sprig: **Account → API keys → Generate**. It is shown once — copy it
14
+ then. Pick read-only unless you actually want the assistant logging care; you can change a key's
15
+ access level later without re-issuing it.
16
+
17
+ **2. Add the server** to your MCP client's config:
18
+
11
19
  ```jsonc
12
- // Add to your MCP client config, restart it, and you're done.
13
20
  {
14
21
  "mcpServers": {
15
22
  "plants": {
@@ -24,176 +31,112 @@ plant collection in **Sprig**, so you can just ask:
24
31
  }
25
32
  ```
26
33
 
27
- Full walkthrough in [Setup](#setup). The app also has this guide built in, with your key and
28
- server address already filled in: **Account → API keys → How to connect an assistant**.
29
-
30
- ## What is Sprig?
31
-
32
- Sprig is a web app for keeping a plant collection alive and on schedule. You add your plants with
33
- their type, location, soil and pot; it tracks watering and fertilizing cycles and tells you what is
34
- due today, this week, and what you have fallen behind on. Every plant keeps a full history —
35
- acquisition, propagation, repotting, photos, health notes — and the app derives care
36
- recommendations from it (chronic lateness, a missed fertilizing season, a plant repotted too
37
- recently to feed). It also covers soil mixes, fertilizers and pots as your own libraries, plus
38
- sharing and trading with other growers.
39
-
40
- This package exposes the care-relevant part of that to an assistant. It is **not** a standalone
41
- tool: it needs a running Sprig instance and an API key from it. If you don't have one, start at
42
- the [app repository](https://github.com/sebamomann/plants).
43
-
44
- ## What this server gives an assistant
45
-
46
- **23 tools** over your collection: 17 that read it, 6 that log care.
47
-
48
- Reads cover plants, watering and fertilization history, the full care timeline, photo metadata,
49
- health entries, the derived care schedule, collection-wide activity, and the lookup catalogs.
50
- Writes cover recording waterings and fertilizings, health notes, and dismissing care
51
- recommendations.
52
-
53
- **Reads need only the `read` scope, which every API key has. Writes need a key created with the
54
- `write` scope.** There are no destructive tools — nothing here deletes a plant, an event, a photo,
55
- or a catalog entry. See [Permissions](#permissions--scopes).
56
-
57
- Every tool is a thin proxy over the app's `/api/v1/*` REST API, authenticated with a per-user API
58
- key. All results are scoped to that key's owner, and the server holds no logic of its own — which
59
- is why it stays correct as the app grows.
60
-
61
- ---
34
+ | Client | Where that config lives |
35
+ |---|---|
36
+ | **Claude Desktop** | Settings → Developer → Edit Config (`claude_desktop_config.json`) |
37
+ | **Claude Code** | `.mcp.json` in your project folder, or run `claude mcp add` |
62
38
 
63
- ## Setup
39
+ **3. Restart the client.** The tool list is exchanged once at handshake, so a running client will
40
+ not see the server — or new tools after an upgrade — until it reconnects.
64
41
 
65
- ### 1. Generate an API key
42
+ **4. Ask it something.** *"What needs watering today?"* A wrong key comes back as a readable 401
43
+ rather than a silent failure.
66
44
 
67
- In the app: **Account API keys Generate**. Choose:
45
+ Requires **Node.js 20 or newer**. Nothing to install by hand: `npx` fetches the package the first
46
+ time your client starts the server.
68
47
 
69
- | Key type | Scopes | Can do |
70
- |---|---|---|
71
- | **Read-only** | `read` | Every read tool. Write tools return HTTP 403. |
72
- | **Read & write** | `read`, `write` | Everything, including the 6 care-logging tools. |
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**.
73
50
 
74
- The key is shown **once** — copy it then. Format: `sprig_live_…`. Pick read-only unless you
75
- actually want the assistant logging care; you can change a key's access level later without
76
- re-issuing it.
51
+ ## What is Sprig?
77
52
 
78
- Right after generating, the app offers **How to connect an assistant** that guide hands you the
79
- config block below with your key and server address already filled in.
53
+ A web app for keeping a plant collection alive and on schedule. It tracks watering and fertilizing
54
+ cycles, tells you what is due and what you have fallen behind on, and keeps a full history per
55
+ plant — repottings, photos, health notes — which it turns into care recommendations.
80
56
 
81
- ### 2. Install
57
+ This package exposes the care-relevant part of that to an assistant. It is **not** standalone: it
58
+ needs a running Sprig instance and an API key from it. Start at the
59
+ [app repository](https://github.com/sebamomann/plants) if you don't have one.
82
60
 
83
- Nothing to install by hand — `npx` fetches the package on demand the first time your client starts
84
- the server. **Requires Node.js 20 or newer.** To check it runs at all:
61
+ ## What an assistant can do
85
62
 
86
- ```sh
87
- PLANT_API_KEY=sprig_live_... npx @sebamomann/plants-mcp
88
- ```
63
+ **27 tools** over your collection: 17 that read it, 10 that write to it.
89
64
 
90
- It should print `[plants-mcp] v… connected.` on stderr and then wait for a client on stdin. That is
91
- a healthy server, not a hang press Ctrl-C.
65
+ Reads cover plants, watering and fertilization history, the full care timeline, photo metadata,
66
+ health entries, the derived care schedule, collection-wide activity, and the lookup catalogs.
67
+ Writes cover recording waterings and fertilizings, health notes, dismissing care recommendations,
68
+ editing a plant's care schedule or settings, and deleting a watering or fertilization event.
92
69
 
93
- To work on it locally instead, clone the app repo this server lives in it as the `mcp`
94
- workspace and build from the repo root:
70
+ **Deleting a plant, a photo, or a catalog entry is not possible from here** that stays UI-only.
71
+ `delete_watering_event` and `delete_fertilization_event` are the two exceptions (see **What this
72
+ server deliberately cannot do** below). Reads need only the `read` scope that every key has;
73
+ writes need a key created with the `write` scope.
95
74
 
96
- ```sh
97
- git clone https://github.com/sebamomann/plants.git
98
- cd plants
99
- npm install
100
- npm run mcp:build # -> mcp/dist/index.js
101
- ```
75
+ Every tool is a thin proxy over the app's `/api/v1/*` REST API. Results are scoped to the key's
76
+ owner, and the server holds no logic of its own — which is why it stays correct as the app grows.
102
77
 
103
- ### 3. Configure
78
+ ## Configuration
104
79
 
105
80
  Two environment variables (see `.env.example`):
106
81
 
107
82
  | Variable | Required | Default | Description |
108
83
  |---|---|---|---|
84
+ | `PLANT_API_KEY` | **yes** | — | The `sprig_live_…` key. The server exits 1 at boot if it's missing. |
109
85
  | `PLANT_API_URL` | no | `http://localhost:3000` | Base URL of the running app. Trailing slashes are stripped. |
110
- | `PLANT_API_KEY` | **yes** | — | The `sprig_live_…` key from step 1. The server exits 1 at boot if it's missing. |
111
86
 
112
- ## Register with Claude
87
+ To check the server runs at all:
113
88
 
114
- Add the server to your client's config:
115
-
116
- ```json
117
- {
118
- "mcpServers": {
119
- "plants": {
120
- "command": "npx",
121
- "args": ["-y", "@sebamomann/plants-mcp"],
122
- "env": {
123
- "PLANT_API_URL": "https://your-sprig-instance",
124
- "PLANT_API_KEY": "sprig_live_..."
125
- }
126
- }
127
- }
128
- }
89
+ ```sh
90
+ PLANT_API_KEY=sprig_live_... npx @sebamomann/plants-mcp
129
91
  ```
130
92
 
131
- Where that config lives:
132
-
133
- | Client | Location |
134
- |---|---|
135
- | **Claude Desktop** | Settings → Developer → Edit Config (`claude_desktop_config.json`) |
136
- | **Claude Code** | `.mcp.json` in your project folder, or run `claude mcp add` |
137
-
138
- Then **restart the client**. The tool list is exchanged once at handshake, so a running client will
139
- not see the server — or newly added tools after an upgrade — until it reconnects.
140
-
141
- To verify, ask your assistant *"what needs watering today?"*. If the key is wrong you get a 401
142
- back as readable text rather than a silent failure.
143
-
144
- To run a local checkout instead, point at the build output —
145
- `"command": "node", "args": ["/…/plants/mcp/dist/index.js"]` — or skip the build and use `tsx`:
146
- `"command": "npx", "args": ["tsx", "/…/plants/mcp/src/index.ts"]`.
147
-
148
- ---
93
+ It prints `[plants-mcp] v… connected.` on stderr and then waits for a client on stdin. That is a
94
+ healthy server, not a hang — press Ctrl-C.
149
95
 
150
96
  ## Permissions & scopes
151
97
 
152
98
  The scope lives on the API key, is enforced by the app, and cannot be widened from this side.
153
99
 
154
- - **`read`** — on every key. Gates all GET endpoints, i.e. all 17 read tools.
155
- - **`write`** — opt-in when you create the key. Gates every mutating endpoint.
156
-
157
- What the failure modes look like:
100
+ - **`read`** — on every key. Gates all 17 read tools.
101
+ - **`write`** — opt-in when you create the key. Gates the 10 write tools.
158
102
 
159
103
  | Situation | HTTP | What the assistant sees |
160
104
  |---|---|---|
161
105
  | Missing, malformed, or revoked key | 401 | The error text, verbatim. Revoking takes effect immediately. |
162
- | Valid read-only key calling a write tool | 403 | The error text, so it can tell you the key can't write. |
106
+ | Read-only key calling a write tool | 403 | The error text, so it can tell you the key can't write. |
163
107
  | Another user's plant id | 404 | Indistinguishable from a nonexistent id — by design. |
164
108
 
165
109
  **Adding a write tool never retroactively widens an existing read-only key.** That is the whole
166
- point of the scope split: keys created before a write tool existed cannot call it.
110
+ point of the scope split.
167
111
 
168
112
  ### What this server deliberately cannot do
169
113
 
170
114
  Not oversights — deliberate limits:
171
115
 
172
- - **No deletes.** No tool removes a plant, event, photo, or catalog entry. The blast radius of a
173
- misunderstood delete is a multi-year history. Retiring a plant is a *status* change, and both
174
- resolving a health entry and dismissing a recommendation are reversible.
175
- - **No sales or trades.** Not readable, not writable, not planned. Those records name a second
176
- person who never consented to your API key, and the actions are socially irreversible.
177
- - **No share-link creation.** Minting a public URL for collection data is a decision for the UI.
178
- - **No cross-user access.** Ownership is enforced per-request from the key; a tool has no way to
179
- even express "someone else's plant".
116
+ - **No deletes, except two.** Deleting a plant, a photo, or a catalog entry stays UI-only the
117
+ blast radius of a misunderstood delete there is a multi-year history, and retiring a plant is a
118
+ *status* change instead. `delete_watering_event` and `delete_fertilization_event` are the
119
+ exceptions: a mis-logged event has no such reversible alternative, so those two are destructive
120
+ and **cannot be undone**.
121
+ - **No sales or trades.** Those records name a second person who never consented to your API key.
122
+ - **No share-link creation.** Minting a public URL for your collection is a decision for the UI.
123
+ - **No cross-user access.** Ownership comes from the key; a tool has no way to even express
124
+ "someone else's plant".
180
125
 
181
126
  ---
182
127
 
183
128
  ## Read tools
184
129
 
185
- ### Identity
186
-
187
- | Tool | Endpoint | Arguments |
188
- |---|---|---|
189
- | `whoami` | `GET /api/v1/me` | none |
190
-
191
130
  ### Plants
192
131
 
193
132
  | Tool | Endpoint | Arguments |
194
133
  |---|---|---|
195
- | `list_plants` | `GET /api/v1/plants` | `status` (`LIVING` \| `DEAD` \| `GIFTED` \| `LOST` \| `SOLD` \| `TRADED` \| `MERGED`), `lifecycle` (`PROPAGATING` \| `ESTABLISHED`), `locationId`, `plantTypeId`, `soilId`, `fertilizerId`, `search` (free text over type name / notes), `limit` (1–200), `offset`, `sort` (e.g. `name`, `createdAt`, `updatedAt`) — all optional |
196
- | `get_plant` | `GET /api/v1/plants/:id` | `id` **(required)** full detail: catalogs, care config, lineage, recent event summaries |
134
+ | `whoami` | `GET /api/v1/me` | none who the key belongs to |
135
+ | `list_plants` | `GET /api/v1/plants` | all optional: `status`, `lifecycle`, `locationId`, `plantTypeId`, `soilId`, `fertilizerId`, `search` (type name / notes), `limit` (1–200), `offset`, `sort` |
136
+ | `get_plant` | `GET /api/v1/plants/:id` | `id` **(required)** — full detail: catalogs, care config, lineage, recent events |
137
+
138
+ `status` is one of `LIVING`, `DEAD`, `GIFTED`, `LOST`, `SOLD`, `TRADED`, `MERGED`; `lifecycle` is
139
+ `PROPAGATING` or `ESTABLISHED`.
197
140
 
198
141
  ### Per-plant history
199
142
 
@@ -211,22 +154,22 @@ All five take the same arguments: `id` **(required)**, `limit` (1–500), `offse
211
154
 
212
155
  These return schedule state the **server** derives, using the same helpers the app's own calendar
213
156
  uses — season, care mode, and snoozes are already applied. Prefer them over recomputing due dates
214
- from raw event lists. They cover `LIVING` plants only.
157
+ from raw event lists. `LIVING` plants only.
215
158
 
216
- Shared optional arguments: `date` (`YYYY-MM-DD`, defaults to today), `season` (`summer` \| `winter`,
159
+ Shared optional arguments: `date` (`YYYY-MM-DD`, defaults to today), `season` (`summer` | `winter`,
217
160
  overrides the Apr–Sep default), `locationId`.
218
161
 
219
162
  | Tool | Endpoint | Extra arguments | Answers |
220
163
  |---|---|---|---|
221
- | `list_due_care` | `GET /api/v1/care/due` | `windowDays` (0–60, default 0 = that day only), `includeOverdue` (default true) | "What should I do today?" — overdue plus anything due on `date` |
222
- | `list_overdue_care` | `GET /api/v1/care/overdue` | — | "What have I fallen behind on?" — past-due only, most overdue first, with days late |
164
+ | `list_due_care` | `GET /api/v1/care/due` | `windowDays` (0–60, default 0 = that day only), `includeOverdue` (default true) | "What should I do today?" |
165
+ | `list_overdue_care` | `GET /api/v1/care/overdue` | — | "What have I fallen behind on?" — most overdue first, with days late |
223
166
  | `get_care_calendar` | `GET /api/v1/care/calendar` | `days` (1–60, default 14) | Day-by-day projection plus an overdue group |
224
- | `list_care_recommendations` | `GET /api/v1/care/recommendations` | none | Detected problems: watering-cycle mismatch, chronic lateness, missed seasonal fertilizing, recent repotting, stale photos |
167
+ | `list_care_recommendations` | `GET /api/v1/care/recommendations` | none | Detected problems: cycle mismatch, chronic lateness, missed seasonal fertilizing, recent repotting, stale photos |
225
168
 
226
169
  Three things to know when reading schedule results:
227
170
 
228
- - **Days, not timestamps.** `nextDue` and `date` are local `YYYY-MM-DD` strings. Care is
229
- day-granular, and a UTC timestamp renders as the wrong day east of Greenwich.
171
+ - **Days, not timestamps.** `nextDue` and `date` are local `YYYY-MM-DD` strings a UTC timestamp
172
+ would render as the wrong day east of Greenwich.
230
173
  - **`neverLogged` is its own bucket.** A plant with a cycle but no event yet has no anchor, so no
231
174
  due date can be computed. Those are returned separately rather than reported as overdue.
232
175
  - **Recommendations return i18n message keys plus values**, not rendered prose. Dismissed ones are
@@ -257,80 +200,104 @@ For resolving the ids the filters take. No arguments.
257
200
 
258
201
  ## Write tools
259
202
 
260
- **All six require a `write`-scoped key**; a read-only key gets HTTP 403. Every description starts
203
+ **All ten require a `write`-scoped key**; a read-only key gets HTTP 403. Every description starts
261
204
  with `WRITE:` so a model cannot mistake one for a read.
262
205
 
263
206
  ### Care logging
264
207
 
265
208
  | Tool | Endpoint | Arguments |
266
209
  |---|---|---|
267
- | `record_watering` | `POST /api/v1/care/watering` | `plantIds` **(required,** 1–200**)**, `wateredAt` (`YYYY-MM-DD`, defaults to now, cannot be in the future) |
210
+ | `record_watering` | `POST /api/v1/care/watering` | `plantIds` **(required,** 1–200**)**, `wateredAt` (`YYYY-MM-DD`, defaults to now, no future dates) |
268
211
  | `record_fertilization` | `POST /api/v1/care/fertilization` | `plantIds` **(required,** 1–200**)**, `fertilizerId`, `fertilizerPercent` (0–1000), `fertilizedAt` |
269
212
 
270
- `record_watering` mirrors what the app does on a one-click watering:
271
-
272
- - **Reservoir and hydro plants** are recorded as a refill / top-up automatically, not as a
273
- conventional watering.
274
- - **Plants configured to fertilize with watering** also get a fertilization logged.
213
+ `record_watering` mirrors a one-click watering in the app: reservoir and hydro plants are recorded
214
+ as a refill / top-up automatically, and plants configured to fertilize with watering also get a
215
+ fertilization logged.
275
216
 
276
217
  `record_fertilization` defaults `fertilizerId` and `fertilizerPercent` to **each plant's own
277
218
  settings** when omitted, so a bulk call across differently-configured plants still does the right
278
219
  thing per plant.
279
220
 
221
+ ### Plant edits
222
+
223
+ | Tool | Endpoint | Arguments |
224
+ |---|---|---|
225
+ | `update_plant_care` | `PATCH /api/v1/plants/:id/care` | `plantId` **(required)**, plus any of `wateringMode` (`scheduled`\|`reservoir`\|`hydro`), `fertilizingMode` (`scheduled`\|`with_watering`), `sunRequirement` (1–4), `waterRequirement` (1–3), `wateringFrequencySummer`/`wateringFrequencyWinter` (labels), `wateringFrequencySummerDays`/`wateringFrequencyWinterDays` (1–365), `wateringNotes`, `fertilizingCycleSummerWeeks`/`fertilizingCycleWinterWeeks` (0–52), `fertilizerPercent` (0–1000), `fertilizingNotes` |
226
+ | `update_plant` | `PATCH /api/v1/plants/:id` | `plantId` **(required)**, plus any of `locationId`, `soilId`, `fertilizerId`, `quantity` (0–9999), `notes`, `sitterInstructions` |
227
+
228
+ Both take a `plantId` and edit **only the fields you pass** — an omitted field keeps its current
229
+ value, `null` clears it (unassigns a catalog id, or blanks a text field).
230
+
231
+ `update_plant_care`'s watering fields (other than `sunRequirement`) only take effect while the
232
+ plant's watering is on a schedule — a reservoir/hydro plant has no cycle for them to configure, so
233
+ they're silently ignored for one. The fertilizing-cycle fields are likewise ignored while the plant
234
+ fertilizes with every watering rather than on its own cycle. Passing `wateringMode`/`fertilizingMode`
235
+ in the **same call** as the fields they gate checks against the *new* mode, not the current one — so
236
+ switching a plant to `"reservoir"` while also setting a watering cycle just drops that cycle rather
237
+ than saving it. `careMode` (a legacy mirror of `wateringMode`) updates itself; it isn't a field you set.
238
+ This is also the tool that actually fixes a `wateringCycleMismatch` recommendation — that
239
+ recommendation's own text says "edit the plant," and the mode causing the mismatch lives here.
240
+
241
+ `update_plant`'s `locationId`/`soilId`/`fertilizerId` must be one of **your own** catalog ids (from
242
+ `list_locations`/`list_soils`/`list_fertilizers`) — an unowned or unknown id is rejected.
243
+
280
244
  ### Health entries
281
245
 
282
246
  | Tool | Endpoint | Arguments |
283
247
  |---|---|---|
284
248
  | `add_health_entry` | `POST /api/v1/plants/:id/health` | `plantId` **(required)**, `kind` **(required,** `observation` \| `issue`**)**, `text` **(required,** 1–1000 chars**)**, `category` (≤80 chars, e.g. `pests`), `severity` (`low` \| `medium` \| `high`) |
285
- | `update_health_entry` | `PATCH /api/v1/health-entries/:id` | `entryId` **(required)**, plus any of `kind`, `text`, `category`, `severity` — omitted fields keep their current value |
249
+ | `update_health_entry` | `PATCH /api/v1/health-entries/:id` | `entryId` **(required)**, plus any of `kind`, `text`, `category`, `severity` — omitted fields keep their value |
286
250
  | `resolve_health_entry` | `PATCH /api/v1/health-entries/:id` | `entryId` **(required)**, `resolved` (default `true`; pass `false` to reopen) |
287
251
 
288
252
  `kind` is the distinction that matters: `observation` for a neutral note ("new leaf unfurling"),
289
253
  `issue` for a problem ("spider mites on undersides").
290
254
 
291
- The two edit tools take an **`entryId`, not a `plantId`** — get it from `list_health_entries`. The
292
- entry id is already unique and ownership-checked, so nesting it under a plant would only add a way
293
- to get it wrong. Resolving is reversible in both directions.
255
+ The two edit tools take an **`entryId`, not a `plantId`** — get it from `list_health_entries`.
294
256
 
295
257
  ### Recommendations
296
258
 
297
259
  | Tool | Endpoint | Arguments |
298
260
  |---|---|---|
299
- | `dismiss_care_recommendation` | `POST /api/v1/care/recommendations/dismiss` | `plantId` **(required)**, `type` **(required)**, `fingerprint` **(required)** |
261
+ | `dismiss_care_recommendation` | `POST /api/v1/care/recommendations/dismiss` | `plantId`, `type`, `fingerprint` **all required** |
300
262
 
301
263
  Pass all three **exactly as returned by `list_care_recommendations`**. The `fingerprint` identifies
302
- that specific occurrence, so dismissing one never suppresses a later, different recurrence of the
303
- same problem.
264
+ that specific occurrence, so dismissing one never suppresses a later, different recurrence.
304
265
 
305
266
  Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `noFertilizerThisSeason`,
306
- `recentlyRepottedAvoidFertilizer`, `noRecentPhoto`.
267
+ `recentlyRepottedAvoidFertilizer`, `noRecentPhoto`. `wateringCycleMismatch` is **not** dismissible —
268
+ it is a configuration contradiction, fixed by editing the plant rather than hidden.
307
269
 
308
- `wateringCycleMismatch` is **not** dismissible — it is a configuration contradiction, fixed by
309
- editing the plant rather than hidden.
270
+ ### Deleting events
271
+
272
+ | Tool | Endpoint | Arguments |
273
+ |---|---|---|
274
+ | `delete_watering_event` | `DELETE /api/v1/plants/:id/watering-events/:eventId` | `plantId`, `eventId` — both **required** |
275
+ | `delete_fertilization_event` | `DELETE /api/v1/plants/:id/fertilization-events/:eventId` | `plantId`, `eventId` — both **required** |
276
+
277
+ **These are the only destructive tools in this server, and neither can be undone.** Take `eventId`
278
+ from `list_watering_events` / `list_fertilization_events` / `list_care_events`. For a reservoir or
279
+ hydro plant, `delete_watering_event` deletes its refill/top-up event instead — the same routing
280
+ `record_watering` uses on the write side.
310
281
 
311
282
  ### Write semantics
312
283
 
313
284
  Two guarantees that make retries and bulk calls safe:
314
285
 
315
- **Idempotent per day.** A plant already watered or fertilized on the target day is **skipped** and
316
- returned in a `skipped` list rather than logged twice. This matters more than it sounds: care
317
- schedules are computed from the newest event, so a duplicate would silently shift every future due
318
- date. A retried call after a timeout is safe.
319
-
320
- **Bulk over loops.** `record_watering` and `record_fertilization` take up to 200 `plantIds` in one
321
- call — prefer that over a loop. Unknown ids come back in an `invalid` list instead of failing the
322
- whole call, so one bad id doesn't lose the other 199.
286
+ - **Idempotent per day.** A plant already watered or fertilized on the target day is **skipped**,
287
+ not logged twice. Care schedules are computed from the newest event, so a duplicate would
288
+ silently shift every future due date. A retry after a timeout is safe.
289
+ - **Bulk over loops.** Both care tools take up to 200 `plantIds` in one call. Unknown ids come back
290
+ in an `invalid` list instead of failing the whole call.
323
291
 
324
- So a successful bulk write is not all-or-nothing: read the `skipped` and `invalid` lists in the
325
- response, not just the HTTP status.
292
+ A successful bulk write is therefore not all-or-nothing: read the `skipped` and `invalid` lists in
293
+ the response, not just the HTTP status.
326
294
 
327
295
  ---
328
296
 
329
297
  ## Responses and errors
330
298
 
331
- Tools return the API's JSON **verbatim**, pretty-printed with 2-space indentation. Nothing is
332
- reshaped, renamed, or summarised — which is why an added field on an API response needs no new
333
- version of this package.
299
+ Tools return the API's JSON **verbatim**, pretty-printed. Nothing is reshaped, renamed, or
300
+ summarised — which is why an added field on an API response needs no new version of this package.
334
301
 
335
302
  Failures come back as MCP errors (`isError: true`) with the detail in the text, so the assistant
336
303
  can act on them or explain them to you:
@@ -340,11 +307,7 @@ can act on them or explain them to you:
340
307
  | Network / DNS / connection refused | `Network error calling <path>: <error>` |
341
308
  | Non-2xx response | `Request to <path> failed (HTTP <status>): <body>` |
342
309
 
343
- A non-JSON body is passed through as raw text rather than being swallowed. If `PLANT_API_KEY` is
344
- unset the process exits 1 at boot with a message on stderr — the client will report the server as
345
- failed to start.
346
-
347
- ---
310
+ A non-JSON body is passed through as raw text rather than being swallowed.
348
311
 
349
312
  ## Versioning
350
313
 
@@ -356,34 +319,36 @@ Semver against the **tool surface**, which is this package's public API:
356
319
  | Bug fix, description wording, dependency bump | **patch** |
357
320
  | Tool removed or renamed, argument removed or made required, env var renamed | **major** |
358
321
 
359
- The version reported in the MCP handshake is read from `package.json` at boot, so it always matches
360
- the installed package. See `CHANGELOG.md` for what changed, and `AGENTS.md` for the release process.
361
-
362
322
  One caveat worth stating plainly: because tools pass the API's JSON straight through, a **response
363
323
  shape change in the app can affect you without this package changing version**. Added fields are
364
324
  safe; renames and removals are not.
365
325
 
326
+ See `CHANGELOG.md` for what changed.
327
+
366
328
  ## Development
367
329
 
368
- From the repo root:
330
+ This server lives in the app repo as the `mcp` workspace. From the repo root:
369
331
 
370
332
  ```sh
333
+ git clone https://github.com/sebamomann/plants.git && cd plants && npm install
334
+
371
335
  npm run mcp:dev # run from source via tsx, no build step
372
- npm run mcp:build # tsc -> mcp/dist/
373
- npm run mcp:typecheck # tsc --noEmit (also part of the root `npm run check`)
336
+ npm run mcp:build # tsc -> mcp/dist/index.js
337
+ npm run mcp:typecheck # also part of the root `npm run check`
374
338
  ```
375
339
 
376
- The whole server is `mcp/src/index.ts`. It needs the app running (default
377
- `http://localhost:3000`) and a real API key to do anything useful. `mcp/AGENTS.md` documents the
378
- architecture rules; the canonical spec for the tool surface is `docs/mcp-server.md` at the repo
379
- root.
340
+ To point a client at your checkout, use `"command": "node", "args": ["/…/plants/mcp/dist/index.js"]`
341
+ or skip the build with `"command": "npx", "args": ["tsx", "/…/plants/mcp/src/index.ts"]`.
342
+
343
+ The whole server is `mcp/src/index.ts`. `mcp/AGENTS.md` has the architecture rules; the canonical
344
+ spec for the tool surface is `docs/mcp-server.md` at the repo root.
380
345
 
381
346
  ## Security
382
347
 
383
- - The key is sent only to `PLANT_API_URL`, as a bearer token. Keep it secret; treat it like a
384
- password. It is passed via environment variable, so it never lands in the tool arguments a model
385
- can see or echo.
348
+ - The key is sent only to `PLANT_API_URL`, as a bearer token. It is passed via environment
349
+ variable, so it never lands in the tool arguments a model can see or echo. Treat it like a
350
+ password.
386
351
  - Prefer a **read-only key** unless you specifically want an assistant logging care.
387
- - Revoke a key any time from **Account → API Keys**; requests with it start returning 401
352
+ - Revoke a key any time from **Account → API keys**; requests with it start returning 401
388
353
  immediately.
389
354
  - All data is scoped to the key's owner. Another user's data returns 404.
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
  import { z } from "zod";
6
6
  /**
7
7
  * MCP server for the Sprig plant app: reads the collection, and — with a
8
- * write-scoped key — logs care. There are no destructive tools.
8
+ * write-scoped key — logs care, edits a plant, and deletes a watering or
9
+ * fertilization event. Plants, photos, and catalog entries stay UI-only.
9
10
  *
10
11
  * Every tool is a thin proxy over the app's `/api/v1/*` REST endpoints,
11
12
  * authenticated with a per-user API key sent as `Authorization: Bearer <key>`.
@@ -71,7 +72,7 @@ async function apiGet(path, query = {}) {
71
72
  /**
72
73
  * Sends a JSON body to a mutating endpoint. Requires an API key with the
73
74
  * `write` scope — a read-only key gets a 403 surfaced back to the model verbatim
74
- * so it can tell the user their key can't write.
75
+ * so it can tell the user their key can't write. `DELETE` sends no body.
75
76
  */
76
77
  async function apiSend(method, path, body) {
77
78
  const url = new URL(`${API_URL}${path}`);
@@ -82,9 +83,9 @@ async function apiSend(method, path, body) {
82
83
  headers: {
83
84
  Authorization: `Bearer ${API_KEY}`,
84
85
  Accept: "application/json",
85
- "Content-Type": "application/json",
86
+ ...(body ? { "Content-Type": "application/json" } : {}),
86
87
  },
87
- body: JSON.stringify(body),
88
+ ...(body ? { body: JSON.stringify(body) } : {}),
88
89
  });
89
90
  }
90
91
  catch (err) {
@@ -222,6 +223,31 @@ server.tool("record_fertilization", "WRITE: log a fertilization for one or more
222
223
  .describe("Strength as a percentage of the base dose; defaults to each plant's own."),
223
224
  fertilizedAt: optionalPlantDate(""),
224
225
  }, async (args) => apiSend("POST", "/api/v1/care/fertilization", { ...args }));
226
+ server.tool("update_plant_care", "WRITE: edit a plant's care schedule — watering mode, days/labels, sun and water requirement, fertilizing mode, cycles and percent, and the watering/fertilizing notes. Omitted fields keep their current value; null clears a notes/label field. Watering fields other than sunRequirement are ignored while the plant's watering isn't on a schedule (reservoir/hydro), and the fertilizing-cycle fields are ignored while it fertilizes with every watering — there's no cycle to configure in either case. If wateringMode/fertilizingMode is part of this same call, that check runs against the NEW mode, not the plant's current one — e.g. switching to 'reservoir' while also passing a watering cycle ignores that cycle. Use this (not update_plant) to fix a 'wateringCycleMismatch' recommendation where the mode itself is wrong. Requires a write-scoped API key.", {
227
+ plantId: z.number().int().positive().describe("Plant id."),
228
+ wateringMode: z.enum(["scheduled", "reservoir", "hydro"]).optional().describe("How this plant is watered: on a schedule, from a reservoir, or in water/hydro culture."),
229
+ fertilizingMode: z.enum(["scheduled", "with_watering"]).optional().describe("How this plant is fertilized: on its own cycle, or automatically with every watering."),
230
+ sunRequirement: z.number().int().min(1).max(4).optional().describe("1 (low light) to 4 (full sun)."),
231
+ waterRequirement: z.number().int().min(1).max(3).optional().describe("1 (low) to 3 (high). Ignored unless watering is scheduled."),
232
+ wateringFrequencySummer: z.string().max(50).nullable().optional().describe("Free-text summer watering label, e.g. 'twice a week'. Ignored unless watering is scheduled."),
233
+ wateringFrequencySummerDays: z.number().int().min(1).max(365).optional().describe("Summer watering interval in days. Ignored unless watering is scheduled."),
234
+ wateringFrequencyWinter: z.string().max(50).nullable().optional().describe("Free-text winter watering label. Ignored unless watering is scheduled."),
235
+ wateringFrequencyWinterDays: z.number().int().min(1).max(365).optional().describe("Winter watering interval in days. Ignored unless watering is scheduled."),
236
+ wateringNotes: z.string().max(500).nullable().optional().describe("Free-text watering notes. Ignored unless watering is scheduled."),
237
+ fertilizingCycleSummerWeeks: z.number().int().min(0).max(52).optional().describe("Summer fertilizing interval in weeks. Ignored while fertilizing is 'with every watering'."),
238
+ fertilizingCycleWinterWeeks: z.number().int().min(0).max(52).optional().describe("Winter fertilizing interval in weeks. Ignored while fertilizing is 'with every watering'."),
239
+ fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose."),
240
+ fertilizingNotes: z.string().max(500).nullable().optional().describe("Free-text fertilizing notes."),
241
+ }, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}/care`, body));
242
+ server.tool("update_plant", "WRITE: edit a plant's location, soil, fertilizer, quantity, notes, or sitter instructions. Omitted fields keep their current value; null unassigns a catalog entry or clears notes/instructions. Requires a write-scoped API key.", {
243
+ plantId: z.number().int().positive().describe("Plant id."),
244
+ locationId: z.number().int().positive().nullable().optional().describe("New location id, from list_locations. null unassigns it."),
245
+ soilId: z.number().int().positive().nullable().optional().describe("New soil id, from list_soils. null unassigns it."),
246
+ fertilizerId: z.number().int().positive().nullable().optional().describe("New fertilizer id, from list_fertilizers. null unassigns it."),
247
+ quantity: z.number().int().min(0).max(9999).nullable().optional().describe("How many plants this row represents."),
248
+ notes: z.string().max(5000).nullable().optional().describe("Free-text notes."),
249
+ sitterInstructions: z.string().max(500).nullable().optional().describe("Care instructions shown on a sitter link."),
250
+ }, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}`, body));
225
251
  server.tool("add_health_entry", "WRITE: add a manual health note to one plant — an observation (e.g. 'new leaf unfurling') or an issue (e.g. 'spider mites on undersides'). Requires a write-scoped API key.", {
226
252
  plantId: z.number().int().positive().describe("Plant id."),
227
253
  kind: z.enum(["observation", "issue"]).describe("'observation' for a neutral note, 'issue' for a problem."),
@@ -253,6 +279,21 @@ server.tool("dismiss_care_recommendation", "WRITE: hide one care recommendation,
253
279
  .describe("Recommendation type, from list_care_recommendations."),
254
280
  fingerprint: z.string().min(1).describe("Occurrence fingerprint, from list_care_recommendations."),
255
281
  }, async (args) => apiSend("POST", "/api/v1/care/recommendations/dismiss", { ...args }));
282
+ // --- Deleting events ---------------------------------------------------------
283
+ /**
284
+ * The only destructive tools in this server. Everything else — plants,
285
+ * photos, catalog entries — stays UI-only; these two exist because a
286
+ * mis-logged watering or fertilization is otherwise stuck in the history
287
+ * forever (there's no "undo" once the day has passed).
288
+ */
289
+ server.tool("delete_watering_event", "WRITE: delete a watering-type event from a plant's history. This is destructive and cannot be undone. For a reservoir/hydro plant this deletes its refill/top-up event instead — pass the id exactly as returned by list_watering_events or list_care_events. Requires a write-scoped API key.", {
290
+ plantId: z.number().int().positive().describe("Plant id."),
291
+ eventId: z.number().int().positive().describe("Event id, from list_watering_events or list_care_events."),
292
+ }, async ({ plantId, eventId }) => apiSend("DELETE", `/api/v1/plants/${plantId}/watering-events/${eventId}`));
293
+ server.tool("delete_fertilization_event", "WRITE: delete a fertilization event from a plant's history. This is destructive and cannot be undone. Pass the id exactly as returned by list_fertilization_events or list_care_events. Requires a write-scoped API key.", {
294
+ plantId: z.number().int().positive().describe("Plant id."),
295
+ eventId: z.number().int().positive().describe("Event id, from list_fertilization_events or list_care_events."),
296
+ }, async ({ plantId, eventId }) => apiSend("DELETE", `/api/v1/plants/${plantId}/fertilization-events/${eventId}`));
256
297
  // --- Catalogs (for resolving filter ids) -----------------------------------
257
298
  server.tool("list_locations", "List the user's locations.", async () => apiGet("/api/v1/locations"));
258
299
  server.tool("list_plant_types", "List the user's plant types (species taxonomy).", async () => apiGet("/api/v1/plant-types"));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sebamomann/plants-mcp",
3
- "version": "1.0.0",
4
- "description": "MCP server for the Sprig plant app: 23 tools to read a plant collection and log care. Read-only by default; waterings, fertilizings, and health notes need a write-scoped API key. No destructive tools.",
3
+ "version": "1.2.0",
4
+ "description": "MCP server for the Sprig plant app: 27 tools to read a plant collection, log care, and edit a plant. Read-only by default; writes need a write-scoped API key. Two tools can delete a watering/fertilization event; nothing else is destructive.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "sebamomann <github@sebamomann.de>",
@@ -43,6 +43,7 @@
43
43
  "start": "node dist/index.js",
44
44
  "dev": "tsx src/index.ts",
45
45
  "typecheck": "tsc --noEmit",
46
+ "check:docs": "node scripts/check-tool-docs.mjs",
46
47
  "prepublishOnly": "npm run build"
47
48
  },
48
49
  "dependencies": {