@sebamomann/plants-mcp 1.0.0 → 1.1.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,20 @@
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.1.0 — 2026-08-03
7
+
8
+ **27 tools: 17 read, 10 write.** Four new tools, all requiring a `write`-scoped key:
9
+
10
+ - **`update_plant_care`** — edit watering days/labels, sun and water requirement, fertilizing
11
+ cycles and percent, and the two care-notes fields. Fields that don't apply to the plant's current
12
+ watering/fertilizing mode (e.g. a watering cycle on a reservoir plant) are silently ignored, same
13
+ as the in-app edit form.
14
+ - **`update_plant`** — edit location, soil, fertilizer, quantity, notes, and sitter instructions.
15
+ `locationId`/`soilId`/`fertilizerId` are ownership-checked before the connect.
16
+ - **`delete_watering_event`** and **`delete_fertilization_event`** — the server's first destructive
17
+ tools. Everything else (plants, photos, catalog entries) is still UI-only; these two exist because
18
+ a mis-logged event has no reversible alternative. Neither can be undone.
19
+
6
20
  ## 1.0.0 — 2026-07-26
7
21
 
8
22
  **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,99 @@ 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 `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.
235
+
236
+ `update_plant`'s `locationId`/`soilId`/`fertilizerId` must be one of **your own** catalog ids (from
237
+ `list_locations`/`list_soils`/`list_fertilizers`) — an unowned or unknown id is rejected.
238
+
280
239
  ### Health entries
281
240
 
282
241
  | Tool | Endpoint | Arguments |
283
242
  |---|---|---|
284
243
  | `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 |
244
+ | `update_health_entry` | `PATCH /api/v1/health-entries/:id` | `entryId` **(required)**, plus any of `kind`, `text`, `category`, `severity` — omitted fields keep their value |
286
245
  | `resolve_health_entry` | `PATCH /api/v1/health-entries/:id` | `entryId` **(required)**, `resolved` (default `true`; pass `false` to reopen) |
287
246
 
288
247
  `kind` is the distinction that matters: `observation` for a neutral note ("new leaf unfurling"),
289
248
  `issue` for a problem ("spider mites on undersides").
290
249
 
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.
250
+ The two edit tools take an **`entryId`, not a `plantId`** — get it from `list_health_entries`.
294
251
 
295
252
  ### Recommendations
296
253
 
297
254
  | Tool | Endpoint | Arguments |
298
255
  |---|---|---|
299
- | `dismiss_care_recommendation` | `POST /api/v1/care/recommendations/dismiss` | `plantId` **(required)**, `type` **(required)**, `fingerprint` **(required)** |
256
+ | `dismiss_care_recommendation` | `POST /api/v1/care/recommendations/dismiss` | `plantId`, `type`, `fingerprint` **all required** |
300
257
 
301
258
  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.
259
+ that specific occurrence, so dismissing one never suppresses a later, different recurrence.
304
260
 
305
261
  Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `noFertilizerThisSeason`,
306
- `recentlyRepottedAvoidFertilizer`, `noRecentPhoto`.
262
+ `recentlyRepottedAvoidFertilizer`, `noRecentPhoto`. `wateringCycleMismatch` is **not** dismissible —
263
+ it is a configuration contradiction, fixed by editing the plant rather than hidden.
307
264
 
308
- `wateringCycleMismatch` is **not** dismissible — it is a configuration contradiction, fixed by
309
- editing the plant rather than hidden.
265
+ ### Deleting events
266
+
267
+ | Tool | Endpoint | Arguments |
268
+ |---|---|---|
269
+ | `delete_watering_event` | `DELETE /api/v1/plants/:id/watering-events/:eventId` | `plantId`, `eventId` — both **required** |
270
+ | `delete_fertilization_event` | `DELETE /api/v1/plants/:id/fertilization-events/:eventId` | `plantId`, `eventId` — both **required** |
271
+
272
+ **These are the only destructive tools in this server, and neither can be undone.** Take `eventId`
273
+ from `list_watering_events` / `list_fertilization_events` / `list_care_events`. For a reservoir or
274
+ hydro plant, `delete_watering_event` deletes its refill/top-up event instead — the same routing
275
+ `record_watering` uses on the write side.
310
276
 
311
277
  ### Write semantics
312
278
 
313
279
  Two guarantees that make retries and bulk calls safe:
314
280
 
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.
281
+ - **Idempotent per day.** A plant already watered or fertilized on the target day is **skipped**,
282
+ not logged twice. Care schedules are computed from the newest event, so a duplicate would
283
+ silently shift every future due date. A retry after a timeout is safe.
284
+ - **Bulk over loops.** Both care tools take up to 200 `plantIds` in one call. Unknown ids come back
285
+ in an `invalid` list instead of failing the whole call.
323
286
 
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.
287
+ A successful bulk write is therefore not all-or-nothing: read the `skipped` and `invalid` lists in
288
+ the response, not just the HTTP status.
326
289
 
327
290
  ---
328
291
 
329
292
  ## Responses and errors
330
293
 
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.
294
+ Tools return the API's JSON **verbatim**, pretty-printed. Nothing is reshaped, renamed, or
295
+ summarised — which is why an added field on an API response needs no new version of this package.
334
296
 
335
297
  Failures come back as MCP errors (`isError: true`) with the detail in the text, so the assistant
336
298
  can act on them or explain them to you:
@@ -340,11 +302,7 @@ can act on them or explain them to you:
340
302
  | Network / DNS / connection refused | `Network error calling <path>: <error>` |
341
303
  | Non-2xx response | `Request to <path> failed (HTTP <status>): <body>` |
342
304
 
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
- ---
305
+ A non-JSON body is passed through as raw text rather than being swallowed.
348
306
 
349
307
  ## Versioning
350
308
 
@@ -356,34 +314,36 @@ Semver against the **tool surface**, which is this package's public API:
356
314
  | Bug fix, description wording, dependency bump | **patch** |
357
315
  | Tool removed or renamed, argument removed or made required, env var renamed | **major** |
358
316
 
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
317
  One caveat worth stating plainly: because tools pass the API's JSON straight through, a **response
363
318
  shape change in the app can affect you without this package changing version**. Added fields are
364
319
  safe; renames and removals are not.
365
320
 
321
+ See `CHANGELOG.md` for what changed.
322
+
366
323
  ## Development
367
324
 
368
- From the repo root:
325
+ This server lives in the app repo as the `mcp` workspace. From the repo root:
369
326
 
370
327
  ```sh
328
+ git clone https://github.com/sebamomann/plants.git && cd plants && npm install
329
+
371
330
  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`)
331
+ npm run mcp:build # tsc -> mcp/dist/index.js
332
+ npm run mcp:typecheck # also part of the root `npm run check`
374
333
  ```
375
334
 
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.
335
+ To point a client at your checkout, use `"command": "node", "args": ["/…/plants/mcp/dist/index.js"]`
336
+ or skip the build with `"command": "npx", "args": ["tsx", "/…/plants/mcp/src/index.ts"]`.
337
+
338
+ The whole server is `mcp/src/index.ts`. `mcp/AGENTS.md` has the architecture rules; the canonical
339
+ spec for the tool surface is `docs/mcp-server.md` at the repo root.
380
340
 
381
341
  ## Security
382
342
 
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.
343
+ - The key is sent only to `PLANT_API_URL`, as a bearer token. It is passed via environment
344
+ variable, so it never lands in the tool arguments a model can see or echo. Treat it like a
345
+ password.
386
346
  - 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
347
+ - Revoke a key any time from **Account → API keys**; requests with it start returning 401
388
348
  immediately.
389
349
  - 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,29 @@ 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 days/labels, sun and water requirement, fertilizing 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. Requires a write-scoped API key.", {
227
+ plantId: z.number().int().positive().describe("Plant id."),
228
+ sunRequirement: z.number().int().min(1).max(4).optional().describe("1 (low light) to 4 (full sun)."),
229
+ waterRequirement: z.number().int().min(1).max(3).optional().describe("1 (low) to 3 (high). Ignored unless watering is scheduled."),
230
+ wateringFrequencySummer: z.string().max(50).nullable().optional().describe("Free-text summer watering label, e.g. 'twice a week'. Ignored unless watering is scheduled."),
231
+ wateringFrequencySummerDays: z.number().int().min(1).max(365).optional().describe("Summer watering interval in days. Ignored unless watering is scheduled."),
232
+ wateringFrequencyWinter: z.string().max(50).nullable().optional().describe("Free-text winter watering label. Ignored unless watering is scheduled."),
233
+ wateringFrequencyWinterDays: z.number().int().min(1).max(365).optional().describe("Winter watering interval in days. Ignored unless watering is scheduled."),
234
+ wateringNotes: z.string().max(500).nullable().optional().describe("Free-text watering notes. Ignored unless watering is scheduled."),
235
+ fertilizingCycleSummerWeeks: z.number().int().min(0).max(52).optional().describe("Summer fertilizing interval in weeks. Ignored while fertilizing is 'with every watering'."),
236
+ fertilizingCycleWinterWeeks: z.number().int().min(0).max(52).optional().describe("Winter fertilizing interval in weeks. Ignored while fertilizing is 'with every watering'."),
237
+ fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose."),
238
+ fertilizingNotes: z.string().max(500).nullable().optional().describe("Free-text fertilizing notes."),
239
+ }, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}/care`, body));
240
+ 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.", {
241
+ plantId: z.number().int().positive().describe("Plant id."),
242
+ locationId: z.number().int().positive().nullable().optional().describe("New location id, from list_locations. null unassigns it."),
243
+ soilId: z.number().int().positive().nullable().optional().describe("New soil id, from list_soils. null unassigns it."),
244
+ fertilizerId: z.number().int().positive().nullable().optional().describe("New fertilizer id, from list_fertilizers. null unassigns it."),
245
+ quantity: z.number().int().min(0).max(9999).nullable().optional().describe("How many plants this row represents."),
246
+ notes: z.string().max(5000).nullable().optional().describe("Free-text notes."),
247
+ sitterInstructions: z.string().max(500).nullable().optional().describe("Care instructions shown on a sitter link."),
248
+ }, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}`, body));
225
249
  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
250
  plantId: z.number().int().positive().describe("Plant id."),
227
251
  kind: z.enum(["observation", "issue"]).describe("'observation' for a neutral note, 'issue' for a problem."),
@@ -253,6 +277,21 @@ server.tool("dismiss_care_recommendation", "WRITE: hide one care recommendation,
253
277
  .describe("Recommendation type, from list_care_recommendations."),
254
278
  fingerprint: z.string().min(1).describe("Occurrence fingerprint, from list_care_recommendations."),
255
279
  }, async (args) => apiSend("POST", "/api/v1/care/recommendations/dismiss", { ...args }));
280
+ // --- Deleting events ---------------------------------------------------------
281
+ /**
282
+ * The only destructive tools in this server. Everything else — plants,
283
+ * photos, catalog entries — stays UI-only; these two exist because a
284
+ * mis-logged watering or fertilization is otherwise stuck in the history
285
+ * forever (there's no "undo" once the day has passed).
286
+ */
287
+ 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.", {
288
+ plantId: z.number().int().positive().describe("Plant id."),
289
+ eventId: z.number().int().positive().describe("Event id, from list_watering_events or list_care_events."),
290
+ }, async ({ plantId, eventId }) => apiSend("DELETE", `/api/v1/plants/${plantId}/watering-events/${eventId}`));
291
+ 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.", {
292
+ plantId: z.number().int().positive().describe("Plant id."),
293
+ eventId: z.number().int().positive().describe("Event id, from list_fertilization_events or list_care_events."),
294
+ }, async ({ plantId, eventId }) => apiSend("DELETE", `/api/v1/plants/${plantId}/fertilization-events/${eventId}`));
256
295
  // --- Catalogs (for resolving filter ids) -----------------------------------
257
296
  server.tool("list_locations", "List the user's locations.", async () => apiGet("/api/v1/locations"));
258
297
  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.1.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": {