@sebamomann/plants-mcp 0.1.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 ADDED
@@ -0,0 +1,69 @@
1
+ # Changelog
2
+
3
+ Notable changes to `@sebamomann/plants-mcp`. Versioning is semver against the **tool surface** —
4
+ see the table in `AGENTS.md` for what counts as major, minor, and patch.
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
+
20
+ ## 1.0.0 — 2026-07-26
21
+
22
+ **The tool surface is now stable.** This is the point of the major bump: the 23 tools, their
23
+ arguments, and their descriptions are declared a public contract, and from here a removal or
24
+ rename requires another major. **Nothing broke in this release** — every tool from 0.1.0 works
25
+ identically, and upgrading needs no changes to your client config.
26
+
27
+ - Moved into the app repository as the `mcp` npm workspace, so an `app/api/v1/*` endpoint and the
28
+ tool exposing it now change in one commit. Published package name, `bin`, and tarball contents
29
+ are unchanged; `npx @sebamomann/plants-mcp` is unaffected.
30
+ - Bumped `@modelcontextprotocol/sdk` to ^1.29.0 and `zod` to ^4.4.3. The SDK already resolved zod
31
+ 4 internally, and the split with our zod 3 made `tsc` run out of memory; aligning both on zod 4
32
+ fixes it. The generated JSON Schema is unchanged except that integer arguments now also carry an
33
+ explicit safe-integer `maximum`.
34
+ - Toolchain aligned with the app repo: TypeScript 6, `@types/node` 26. `tsx` now comes from the
35
+ workspace root.
36
+
37
+ ## 0.1.0
38
+
39
+ First published release. 23 tools: 17 read, 6 write.
40
+
41
+ ### Reads
42
+
43
+ - **Identity** — `whoami`.
44
+ - **Plants** — `list_plants` (filter by status, lifecycle, location, type, soil, fertilizer, free
45
+ text; sorting and pagination) and `get_plant`.
46
+ - **Per-plant history** — `list_watering_events`, `list_fertilization_events`, `list_care_events`
47
+ (combined timeline, tagged by kind), `list_photos` (metadata only), `list_health_entries`.
48
+ - **Care schedule** — `list_due_care` (overdue plus the given day, optional look-ahead),
49
+ `list_overdue_care` (past-due only, with days late), `get_care_calendar` (day-by-day projection),
50
+ `list_care_recommendations` (detected care problems, each with a dismissal fingerprint). Season,
51
+ care mode, and snoozes are applied server-side; `LIVING` plants only.
52
+ - **Activity** — `list_recent_activity`, collection-wide care activity over a date range, so a
53
+ question about a time period doesn't need a loop over per-plant tools.
54
+ - **Catalogs** — `list_locations`, `list_plant_types`, `list_soils`, `list_fertilizers`.
55
+
56
+ ### Writes (require a `write`-scoped API key)
57
+
58
+ - **Care logging** — `record_watering` and `record_fertilization`, both bulk (up to 200 plant ids).
59
+ Reservoir and hydro plants route to a refill/top-up; plants that fertilize with watering also get
60
+ a fertilization logged. Fertilizer and strength default to each plant's own settings.
61
+ - **Health entries** — `add_health_entry`, `update_health_entry`, `resolve_health_entry`
62
+ (reversible in both directions), addressed by entry id.
63
+ - **Recommendations** — `dismiss_care_recommendation`, per occurrence via `fingerprint`.
64
+
65
+ Writes are idempotent per day: a plant already watered or fertilized on the target day is returned
66
+ in a `skipped` list rather than logged twice, so retries never shift the care schedule. Unknown ids
67
+ come back in an `invalid` list instead of failing the whole call.
68
+
69
+ There are no destructive tools. Sales and trades are not exposed, by design.
package/README.md CHANGED
@@ -1,45 +1,29 @@
1
- # plants-mcp
2
-
3
- [MCP](https://modelcontextprotocol.io) server for the **Sprig** plant app. It lets a Claude agent query
4
- your plant collectionplants, watering/fertilization events, the full care timeline, photos, health
5
- entries, the care schedule, and lookup catalogs — and, with a write-scoped key, record waterings,
6
- fertilizings, and health notes. It talks to the app's `/api/v1/*` REST API, authenticated with a
7
- **per-user API key**.
8
-
9
- Reads need only the `read` scope (every key has it). The write tools — `record_watering`,
10
- `record_fertilization`, `add_health_entry` — need a key with the `write` scope, chosen when you create
11
- the key. There are no destructive tools: nothing deletes plants, events, or history.
12
-
13
- ## Setup
14
-
15
- 1. **Generate an API key** in the app: **Account → API Keys → Generate**. Pick **Read-only** or
16
- **Read & write** (write is needed for the logging tools). Copy it (it's shown once).
17
- 2. **Install.** Published on npm, so no clone is needed — `npx` fetches it on demand:
18
- ```sh
19
- npx @sebamomann/plants-mcp
20
- ```
21
- To work on it locally instead, clone the repo and build:
22
- ```sh
23
- cd plants-mcp
24
- npm install
25
- npm run build
26
- ```
27
- 3. **Configure** via environment variables (see `.env.example`):
28
- - `PLANT_API_URL` — base URL of the running app (`http://localhost:3000` or your deployed site).
29
- - `PLANT_API_KEY` — the `sprig_live_…` key from step 1.
30
-
31
- ## Register with Claude
32
-
33
- Add to your MCP client config (e.g. Claude Code / Claude Desktop). Example:
34
-
35
- ```json
1
+ # @sebamomann/plants-mcp
2
+
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
+
7
+ > *"What needs watering today?"*
8
+ > *"Which plants am I behind on?"*
9
+ > *"Log that I watered everything on the windowsill."*
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
+
19
+ ```jsonc
36
20
  {
37
21
  "mcpServers": {
38
22
  "plants": {
39
23
  "command": "npx",
40
24
  "args": ["-y", "@sebamomann/plants-mcp"],
41
25
  "env": {
42
- "PLANT_API_URL": "http://localhost:3000",
26
+ "PLANT_API_URL": "https://your-sprig-instance",
43
27
  "PLANT_API_KEY": "sprig_live_..."
44
28
  }
45
29
  }
@@ -47,62 +31,319 @@ Add to your MCP client config (e.g. Claude Code / Claude Desktop). Example:
47
31
  }
48
32
  ```
49
33
 
50
- To run a local checkout instead, point at the build output —
51
- `"command": "node", "args": ["/…/plants-mcp/dist/index.js"]` — or skip the build and use `tsx`:
52
- `"command": "npx", "args": ["tsx", "/…/plants-mcp/src/index.ts"]`.
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` |
38
+
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.
41
+
42
+ **4. Ask it something.** *"What needs watering today?"* A wrong key comes back as a readable 401
43
+ rather than a silent failure.
44
+
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.
47
+
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**.
50
+
51
+ ## What is Sprig?
52
+
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.
56
+
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.
60
+
61
+ ## What an assistant can do
62
+
63
+ **27 tools** over your collection: 17 that read it, 10 that write to it.
64
+
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.
69
+
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.
74
+
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.
77
+
78
+ ## Configuration
79
+
80
+ Two environment variables (see `.env.example`):
81
+
82
+ | Variable | Required | Default | Description |
83
+ |---|---|---|---|
84
+ | `PLANT_API_KEY` | **yes** | — | The `sprig_live_…` key. The server exits 1 at boot if it's missing. |
85
+ | `PLANT_API_URL` | no | `http://localhost:3000` | Base URL of the running app. Trailing slashes are stripped. |
86
+
87
+ To check the server runs at all:
88
+
89
+ ```sh
90
+ PLANT_API_KEY=sprig_live_... npx @sebamomann/plants-mcp
91
+ ```
92
+
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.
53
95
 
54
- ## Tools
96
+ ## Permissions & scopes
55
97
 
56
- | Tool | Endpoint | Purpose |
98
+ The scope lives on the API key, is enforced by the app, and cannot be widened from this side.
99
+
100
+ - **`read`** — on every key. Gates all 17 read tools.
101
+ - **`write`** — opt-in when you create the key. Gates the 10 write tools.
102
+
103
+ | Situation | HTTP | What the assistant sees |
104
+ |---|---|---|
105
+ | Missing, malformed, or revoked key | 401 | The error text, verbatim. Revoking takes effect immediately. |
106
+ | Read-only key calling a write tool | 403 | The error text, so it can tell you the key can't write. |
107
+ | Another user's plant id | 404 | Indistinguishable from a nonexistent id — by design. |
108
+
109
+ **Adding a write tool never retroactively widens an existing read-only key.** That is the whole
110
+ point of the scope split.
111
+
112
+ ### What this server deliberately cannot do
113
+
114
+ Not oversights — deliberate limits:
115
+
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".
125
+
126
+ ---
127
+
128
+ ## Read tools
129
+
130
+ ### Plants
131
+
132
+ | Tool | Endpoint | Arguments |
133
+ |---|---|---|
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`.
140
+
141
+ ### Per-plant history
142
+
143
+ All five take the same arguments: `id` **(required)**, `limit` (1–500), `offset`. Newest first.
144
+
145
+ | Tool | Endpoint | Returns |
57
146
  |---|---|---|
58
- | `whoami` | `GET /api/v1/me` | Identify the authenticated user |
59
- | `list_plants` | `GET /api/v1/plants` | List plants with filters (status, lifecycle, location, type, soil, fertilizer, search, pagination, sort) |
60
- | `get_plant` | `GET /api/v1/plants/:id` | Full detail for one plant |
61
147
  | `list_watering_events` | `GET /api/v1/plants/:id/watering-events` | Watering history |
62
148
  | `list_fertilization_events` | `GET /api/v1/plants/:id/fertilization-events` | Fertilization history |
63
- | `list_care_events` | `GET /api/v1/plants/:id/events` | Combined care timeline (typed by `kind`) |
64
- | `list_photos` | `GET /api/v1/plants/:id/photos` | Photo metadata |
65
- | `list_health_entries` | `GET /api/v1/plants/:id/health` | Health / AI-analysis entries |
66
- | `list_due_care` | `GET /api/v1/care/due` | What needs water/fertilizer now — overdue plus the given day, optional look-ahead window |
67
- | `list_overdue_care` | `GET /api/v1/care/overdue` | Only past-due work, most overdue first, with days late |
68
- | `get_care_calendar` | `GET /api/v1/care/calendar` | Day-by-day schedule over a range, plus an overdue group |
69
- | `list_care_recommendations` | `GET /api/v1/care/recommendations` | Detected care problems (cycle mismatch, chronic lateness, seasonal gaps, stale photos) |
70
- | `list_recent_activity` | `GET /api/v1/activity` | Collection-wide care activity over a date range, typed by `kind` use instead of looping the per-plant event tools |
71
- | `list_locations` | `GET /api/v1/locations` | Location catalog |
72
- | `list_plant_types` | `GET /api/v1/plant-types` | Plant-type (species) catalog |
73
- | `list_soils` | `GET /api/v1/soils` | Soil catalog |
74
- | `list_fertilizers` | `GET /api/v1/fertilizers` | Fertilizer catalog |
75
-
76
- ### Write tools (require the `write` scope)
77
-
78
- | Tool | Endpoint | Purpose |
149
+ | `list_care_events` | `GET /api/v1/plants/:id/events` | Combined timeline watering, fertilization, refill, hydro, potting, snoozes — each tagged with a `kind` |
150
+ | `list_photos` | `GET /api/v1/plants/:id/photos` | Photo **metadata** (urls, `takenAt`). Not the images themselves. |
151
+ | `list_health_entries` | `GET /api/v1/plants/:id/health` | Health and AI-analysis entries. Source of the `entryId` the health write tools take. |
152
+
153
+ ### Care schedule
154
+
155
+ These return schedule state the **server** derives, using the same helpers the app's own calendar
156
+ uses season, care mode, and snoozes are already applied. Prefer them over recomputing due dates
157
+ from raw event lists. `LIVING` plants only.
158
+
159
+ Shared optional arguments: `date` (`YYYY-MM-DD`, defaults to today), `season` (`summer` | `winter`,
160
+ overrides the Apr–Sep default), `locationId`.
161
+
162
+ | Tool | Endpoint | Extra arguments | Answers |
163
+ |---|---|---|---|
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 |
166
+ | `get_care_calendar` | `GET /api/v1/care/calendar` | `days` (1–60, default 14) | Day-by-day projection plus an overdue group |
167
+ | `list_care_recommendations` | `GET /api/v1/care/recommendations` | none | Detected problems: cycle mismatch, chronic lateness, missed seasonal fertilizing, recent repotting, stale photos |
168
+
169
+ Three things to know when reading schedule results:
170
+
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.
173
+ - **`neverLogged` is its own bucket.** A plant with a cycle but no event yet has no anchor, so no
174
+ due date can be computed. Those are returned separately rather than reported as overdue.
175
+ - **Recommendations return i18n message keys plus values**, not rendered prose. Dismissed ones are
176
+ excluded.
177
+
178
+ ### Collection-wide activity
179
+
180
+ | Tool | Endpoint | Arguments |
79
181
  |---|---|---|
80
- | `record_watering` | `POST /api/v1/care/watering` | Log a watering for one or more plants (bulk); reservoir/hydro routed to refill/top-up |
81
- | `record_fertilization` | `POST /api/v1/care/fertilization` | Log a fertilization for one or more plants (bulk) |
82
- | `add_health_entry` | `POST /api/v1/plants/:id/health` | Add a manual observation or issue note to a plant |
83
- | `update_health_entry` | `PATCH /api/v1/health-entries/:id` | Edit an existing entry's kind, text, category, or severity |
84
- | `resolve_health_entry` | `PATCH /api/v1/health-entries/:id` | Mark an entry resolved, or reopen it |
85
- | `dismiss_care_recommendation` | `POST /api/v1/care/recommendations/dismiss` | Hide one recommendation occurrence, by `plantId` + `type` + `fingerprint` |
182
+ | `list_recent_activity` | `GET /api/v1/activity` | `since`, `until` (ISO date or timestamp), `kinds` (any of `watering`, `fertilization`, `refill`, `hydro`, `potting`, `health`, `photo`; defaults to all), `limit` (1–500), `offset` |
86
183
 
87
- Writes are **idempotent per day**: a plant already watered/fertilized on the target day is skipped and
88
- returned in a `skipped` list rather than logged twice, so a retried call never shifts the care schedule.
89
- Unknown plant ids come back in an `invalid` list. A read-only key calling a write tool gets HTTP 403.
184
+ Use this instead of looping the per-plant tools when the question is about a **time period**
185
+ ("what did I water last week?") rather than one plant. Newest first, each entry tagged with a
186
+ `kind` and naming its plant. Does **not** include acquisitions, gifts, sales, or trades.
90
187
 
91
- There are **no destructive tools**. Nothing here deletes a plant, an event, a photo, or a catalog entry;
92
- resolving and dismissing are both reversible, and retiring a plant is a status change. Deletion stays in
93
- the UI on purpose.
188
+ ### Catalogs
94
189
 
95
- The collection's **sales and trades are deliberately not exposed** — not as reads, not as writes. They
96
- involve a second user and are out of scope for an assistant acting on the owner's key.
190
+ For resolving the ids the filters take. No arguments.
191
+
192
+ | Tool | Endpoint |
193
+ |---|---|
194
+ | `list_locations` | `GET /api/v1/locations` |
195
+ | `list_plant_types` | `GET /api/v1/plant-types` |
196
+ | `list_soils` | `GET /api/v1/soils` |
197
+ | `list_fertilizers` | `GET /api/v1/fertilizers` |
198
+
199
+ ---
200
+
201
+ ## Write tools
202
+
203
+ **All ten require a `write`-scoped key**; a read-only key gets HTTP 403. Every description starts
204
+ with `WRITE:` so a model cannot mistake one for a read.
205
+
206
+ ### Care logging
207
+
208
+ | Tool | Endpoint | Arguments |
209
+ |---|---|---|
210
+ | `record_watering` | `POST /api/v1/care/watering` | `plantIds` **(required,** 1–200**)**, `wateredAt` (`YYYY-MM-DD`, defaults to now, no future dates) |
211
+ | `record_fertilization` | `POST /api/v1/care/fertilization` | `plantIds` **(required,** 1–200**)**, `fertilizerId`, `fertilizerPercent` (0–1000), `fertilizedAt` |
212
+
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.
216
+
217
+ `record_fertilization` defaults `fertilizerId` and `fertilizerPercent` to **each plant's own
218
+ settings** when omitted, so a bulk call across differently-configured plants still does the right
219
+ thing per plant.
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
+
239
+ ### Health entries
240
+
241
+ | Tool | Endpoint | Arguments |
242
+ |---|---|---|
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`) |
244
+ | `update_health_entry` | `PATCH /api/v1/health-entries/:id` | `entryId` **(required)**, plus any of `kind`, `text`, `category`, `severity` — omitted fields keep their value |
245
+ | `resolve_health_entry` | `PATCH /api/v1/health-entries/:id` | `entryId` **(required)**, `resolved` (default `true`; pass `false` to reopen) |
246
+
247
+ `kind` is the distinction that matters: `observation` for a neutral note ("new leaf unfurling"),
248
+ `issue` for a problem ("spider mites on undersides").
249
+
250
+ The two edit tools take an **`entryId`, not a `plantId`** — get it from `list_health_entries`.
251
+
252
+ ### Recommendations
253
+
254
+ | Tool | Endpoint | Arguments |
255
+ |---|---|---|
256
+ | `dismiss_care_recommendation` | `POST /api/v1/care/recommendations/dismiss` | `plantId`, `type`, `fingerprint` — **all required** |
257
+
258
+ Pass all three **exactly as returned by `list_care_recommendations`**. The `fingerprint` identifies
259
+ that specific occurrence, so dismissing one never suppresses a later, different recurrence.
260
+
261
+ Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `noFertilizerThisSeason`,
262
+ `recentlyRepottedAvoidFertilizer`, `noRecentPhoto`. `wateringCycleMismatch` is **not** dismissible —
263
+ it is a configuration contradiction, fixed by editing the plant rather than hidden.
264
+
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.
276
+
277
+ ### Write semantics
278
+
279
+ Two guarantees that make retries and bulk calls safe:
280
+
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.
286
+
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.
289
+
290
+ ---
291
+
292
+ ## Responses and errors
293
+
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.
296
+
297
+ Failures come back as MCP errors (`isError: true`) with the detail in the text, so the assistant
298
+ can act on them or explain them to you:
299
+
300
+ | Failure | Text |
301
+ |---|---|
302
+ | Network / DNS / connection refused | `Network error calling <path>: <error>` |
303
+ | Non-2xx response | `Request to <path> failed (HTTP <status>): <body>` |
304
+
305
+ A non-JSON body is passed through as raw text rather than being swallowed.
306
+
307
+ ## Versioning
308
+
309
+ Semver against the **tool surface**, which is this package's public API:
310
+
311
+ | Change | Bump |
312
+ |---|---|
313
+ | New tool, new optional argument | **minor** |
314
+ | Bug fix, description wording, dependency bump | **patch** |
315
+ | Tool removed or renamed, argument removed or made required, env var renamed | **major** |
316
+
317
+ One caveat worth stating plainly: because tools pass the API's JSON straight through, a **response
318
+ shape change in the app can affect you without this package changing version**. Added fields are
319
+ safe; renames and removals are not.
320
+
321
+ See `CHANGELOG.md` for what changed.
322
+
323
+ ## Development
324
+
325
+ This server lives in the app repo as the `mcp` workspace. From the repo root:
326
+
327
+ ```sh
328
+ git clone https://github.com/sebamomann/plants.git && cd plants && npm install
329
+
330
+ npm run mcp:dev # run from source via tsx, no build step
331
+ npm run mcp:build # tsc -> mcp/dist/index.js
332
+ npm run mcp:typecheck # also part of the root `npm run check`
333
+ ```
97
334
 
98
- All results are scoped to the key's owner; another user's data returns 404 and a bad/missing/revoked key
99
- returns 401.
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"]`.
100
337
 
101
- The `care/*` tools return schedule state the server derives from the same helpers the app's own
102
- calendar uses season, care mode, and snoozes are already applied. Prefer them over recomputing due
103
- dates from raw event lists. They cover `LIVING` plants only.
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.
104
340
 
105
341
  ## Security
106
342
 
107
- - The key is sent only to `PLANT_API_URL` as a bearer token. Keep it secret; treat it like a password.
108
- - Revoke a key any time from **Account API Keys**; requests with it immediately start returning 401.
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.
346
+ - Prefer a **read-only key** unless you specifically want an assistant logging care.
347
+ - Revoke a key any time from **Account → API keys**; requests with it start returning 401
348
+ immediately.
349
+ - All data is scoped to the key's owner. Another user's data returns 404.
package/dist/index.js CHANGED
@@ -1,15 +1,25 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { z } from "zod";
5
6
  /**
6
- * Read-only MCP server for the Sprig plant app.
7
+ * MCP server for the Sprig plant app: reads the collection, and — with a
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.
7
10
  *
8
11
  * Every tool is a thin proxy over the app's `/api/v1/*` REST endpoints,
9
12
  * authenticated with a per-user API key sent as `Authorization: Bearer <key>`.
10
13
  * Tools forward their arguments as query params and return the API's JSON
11
14
  * verbatim, so they stay correct even if the API response shape evolves.
12
15
  */
16
+ /**
17
+ * Single source of truth for the advertised version: package.json, so a release
18
+ * bump cannot leave the MCP handshake reporting a stale number. `../package.json`
19
+ * resolves to the package root both from `dist/index.js` (published) and from
20
+ * `src/index.ts` (tsx dev).
21
+ */
22
+ const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
13
23
  const API_URL = (process.env.PLANT_API_URL ?? "http://localhost:3000").replace(/\/+$/, "");
14
24
  const API_KEY = process.env.PLANT_API_KEY;
15
25
  if (!API_KEY) {
@@ -62,7 +72,7 @@ async function apiGet(path, query = {}) {
62
72
  /**
63
73
  * Sends a JSON body to a mutating endpoint. Requires an API key with the
64
74
  * `write` scope — a read-only key gets a 403 surfaced back to the model verbatim
65
- * 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.
66
76
  */
67
77
  async function apiSend(method, path, body) {
68
78
  const url = new URL(`${API_URL}${path}`);
@@ -73,9 +83,9 @@ async function apiSend(method, path, body) {
73
83
  headers: {
74
84
  Authorization: `Bearer ${API_KEY}`,
75
85
  Accept: "application/json",
76
- "Content-Type": "application/json",
86
+ ...(body ? { "Content-Type": "application/json" } : {}),
77
87
  },
78
- body: JSON.stringify(body),
88
+ ...(body ? { body: JSON.stringify(body) } : {}),
79
89
  });
80
90
  }
81
91
  catch (err) {
@@ -105,7 +115,7 @@ async function apiSend(method, path, body) {
105
115
  }
106
116
  return { content: [{ type: "text", text: pretty }] };
107
117
  }
108
- const server = new McpServer({ name: "plants-mcp", version: "0.1.0" });
118
+ const server = new McpServer({ name: "plants-mcp", version: VERSION });
109
119
  // --- Identity ---------------------------------------------------------------
110
120
  server.tool("whoami", "Return the authenticated user for the configured API key.", async () => apiGet("/api/v1/me"));
111
121
  // --- Plants -----------------------------------------------------------------
@@ -213,6 +223,29 @@ server.tool("record_fertilization", "WRITE: log a fertilization for one or more
213
223
  .describe("Strength as a percentage of the base dose; defaults to each plant's own."),
214
224
  fertilizedAt: optionalPlantDate(""),
215
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));
216
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.", {
217
250
  plantId: z.number().int().positive().describe("Plant id."),
218
251
  kind: z.enum(["observation", "issue"]).describe("'observation' for a neutral note, 'issue' for a problem."),
@@ -244,6 +277,21 @@ server.tool("dismiss_care_recommendation", "WRITE: hide one care recommendation,
244
277
  .describe("Recommendation type, from list_care_recommendations."),
245
278
  fingerprint: z.string().min(1).describe("Occurrence fingerprint, from list_care_recommendations."),
246
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}`));
247
295
  // --- Catalogs (for resolving filter ids) -----------------------------------
248
296
  server.tool("list_locations", "List the user's locations.", async () => apiGet("/api/v1/locations"));
249
297
  server.tool("list_plant_types", "List the user's plant types (species taxonomy).", async () => apiGet("/api/v1/plant-types"));
@@ -253,7 +301,7 @@ server.tool("list_fertilizers", "List the user's fertilizers.", async () => apiG
253
301
  async function main() {
254
302
  const transport = new StdioServerTransport();
255
303
  await server.connect(transport);
256
- console.error(`[plants-mcp] connected. API base: ${API_URL}`);
304
+ console.error(`[plants-mcp] v${VERSION} connected. API base: ${API_URL}`);
257
305
  }
258
306
  main().catch((err) => {
259
307
  console.error("[plants-mcp] fatal:", err);
package/package.json CHANGED
@@ -1,22 +1,28 @@
1
1
  {
2
2
  "name": "@sebamomann/plants-mcp",
3
- "version": "0.1.0",
4
- "description": "Read-only MCP server for the Sprig plant app, authenticated with a per-user API key.",
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>",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/sebamomann/plants-mcp.git"
10
+ "url": "git+https://github.com/sebamomann/plants.git",
11
+ "directory": "mcp"
11
12
  },
12
13
  "bugs": {
13
- "url": "https://github.com/sebamomann/plants-mcp/issues"
14
+ "url": "https://github.com/sebamomann/plants/issues"
14
15
  },
15
- "homepage": "https://github.com/sebamomann/plants-mcp#readme",
16
+ "homepage": "https://github.com/sebamomann/plants/tree/main/mcp#readme",
16
17
  "keywords": [
17
18
  "mcp",
19
+ "mcp-server",
18
20
  "model-context-protocol",
21
+ "claude",
19
22
  "plants",
23
+ "houseplants",
24
+ "gardening",
25
+ "plant-care",
20
26
  "sprig"
21
27
  ],
22
28
  "engines": {
@@ -26,7 +32,8 @@
26
32
  "plants-mcp": "dist/index.js"
27
33
  },
28
34
  "files": [
29
- "dist"
35
+ "dist",
36
+ "CHANGELOG.md"
30
37
  ],
31
38
  "publishConfig": {
32
39
  "access": "public"
@@ -36,15 +43,15 @@
36
43
  "start": "node dist/index.js",
37
44
  "dev": "tsx src/index.ts",
38
45
  "typecheck": "tsc --noEmit",
46
+ "check:docs": "node scripts/check-tool-docs.mjs",
39
47
  "prepublishOnly": "npm run build"
40
48
  },
41
49
  "dependencies": {
42
- "@modelcontextprotocol/sdk": "^1.12.0",
43
- "zod": "^3.24.1"
50
+ "@modelcontextprotocol/sdk": "^1.29.0",
51
+ "zod": "^4.4.3"
44
52
  },
45
53
  "devDependencies": {
46
- "@types/node": "^22.10.0",
47
- "tsx": "^4.19.2",
48
- "typescript": "^5.7.2"
54
+ "@types/node": "^26.1.1",
55
+ "typescript": "^6.0.3"
49
56
  }
50
57
  }