@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 +69 -0
- package/README.md +322 -81
- package/dist/index.js +54 -6
- package/package.json +18 -11
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)
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
2.
|
|
18
|
-
|
|
19
|
-
|
|
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": "
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
##
|
|
96
|
+
## Permissions & scopes
|
|
55
97
|
|
|
56
|
-
|
|
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
|
|
64
|
-
| `list_photos` | `GET /api/v1/plants/:id/photos` | Photo metadata |
|
|
65
|
-
| `list_health_entries` | `GET /api/v1/plants/:id/health` | Health
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
|
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
|
-
| `
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
102
|
-
|
|
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
|
|
108
|
-
|
|
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
|
-
*
|
|
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:
|
|
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": "
|
|
4
|
-
"description": "
|
|
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
|
|
10
|
+
"url": "git+https://github.com/sebamomann/plants.git",
|
|
11
|
+
"directory": "mcp"
|
|
11
12
|
},
|
|
12
13
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/sebamomann/plants
|
|
14
|
+
"url": "https://github.com/sebamomann/plants/issues"
|
|
14
15
|
},
|
|
15
|
-
"homepage": "https://github.com/sebamomann/plants
|
|
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.
|
|
43
|
-
"zod": "^
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
51
|
+
"zod": "^4.4.3"
|
|
44
52
|
},
|
|
45
53
|
"devDependencies": {
|
|
46
|
-
"@types/node": "^
|
|
47
|
-
"
|
|
48
|
-
"typescript": "^5.7.2"
|
|
54
|
+
"@types/node": "^26.1.1",
|
|
55
|
+
"typescript": "^6.0.3"
|
|
49
56
|
}
|
|
50
57
|
}
|