@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 +32 -0
- package/README.md +146 -181
- package/dist/index.js +45 -4
- package/package.json +3 -2
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.**
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
the server. **Requires Node.js 20 or newer.** To check it runs at all:
|
|
61
|
+
## What an assistant can do
|
|
85
62
|
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
+
To check the server runs at all:
|
|
113
88
|
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
155
|
-
- **`write`** — opt-in when you create the key. Gates
|
|
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
|
-
|
|
|
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
|
|
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
|
|
173
|
-
misunderstood delete is a multi-year history
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
- **No
|
|
178
|
-
- **No
|
|
179
|
-
|
|
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
|
-
| `
|
|
196
|
-
| `
|
|
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.
|
|
157
|
+
from raw event lists. `LIVING` plants only.
|
|
215
158
|
|
|
216
|
-
Shared optional arguments: `date` (`YYYY-MM-DD`, defaults to today), `season` (`summer`
|
|
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?"
|
|
222
|
-
| `list_overdue_care` | `GET /api/v1/care/overdue` | — | "What have I fallen behind on?" —
|
|
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:
|
|
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
|
|
229
|
-
|
|
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
|
|
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,
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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`.
|
|
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
|
|
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
|
|
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
|
-
|
|
309
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
|
332
|
-
|
|
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.
|
|
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 #
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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.
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"description": "MCP server for the Sprig plant app:
|
|
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": {
|