@sebamomann/plants-mcp 0.1.0 → 1.0.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,55 @@
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.0.0 — 2026-07-26
7
+
8
+ **The tool surface is now stable.** This is the point of the major bump: the 23 tools, their
9
+ arguments, and their descriptions are declared a public contract, and from here a removal or
10
+ rename requires another major. **Nothing broke in this release** — every tool from 0.1.0 works
11
+ identically, and upgrading needs no changes to your client config.
12
+
13
+ - Moved into the app repository as the `mcp` npm workspace, so an `app/api/v1/*` endpoint and the
14
+ tool exposing it now change in one commit. Published package name, `bin`, and tarball contents
15
+ are unchanged; `npx @sebamomann/plants-mcp` is unaffected.
16
+ - Bumped `@modelcontextprotocol/sdk` to ^1.29.0 and `zod` to ^4.4.3. The SDK already resolved zod
17
+ 4 internally, and the split with our zod 3 made `tsc` run out of memory; aligning both on zod 4
18
+ fixes it. The generated JSON Schema is unchanged except that integer arguments now also carry an
19
+ explicit safe-integer `maximum`.
20
+ - Toolchain aligned with the app repo: TypeScript 6, `@types/node` 26. `tsx` now comes from the
21
+ workspace root.
22
+
23
+ ## 0.1.0
24
+
25
+ First published release. 23 tools: 17 read, 6 write.
26
+
27
+ ### Reads
28
+
29
+ - **Identity** — `whoami`.
30
+ - **Plants** — `list_plants` (filter by status, lifecycle, location, type, soil, fertilizer, free
31
+ text; sorting and pagination) and `get_plant`.
32
+ - **Per-plant history** — `list_watering_events`, `list_fertilization_events`, `list_care_events`
33
+ (combined timeline, tagged by kind), `list_photos` (metadata only), `list_health_entries`.
34
+ - **Care schedule** — `list_due_care` (overdue plus the given day, optional look-ahead),
35
+ `list_overdue_care` (past-due only, with days late), `get_care_calendar` (day-by-day projection),
36
+ `list_care_recommendations` (detected care problems, each with a dismissal fingerprint). Season,
37
+ care mode, and snoozes are applied server-side; `LIVING` plants only.
38
+ - **Activity** — `list_recent_activity`, collection-wide care activity over a date range, so a
39
+ question about a time period doesn't need a loop over per-plant tools.
40
+ - **Catalogs** — `list_locations`, `list_plant_types`, `list_soils`, `list_fertilizers`.
41
+
42
+ ### Writes (require a `write`-scoped API key)
43
+
44
+ - **Care logging** — `record_watering` and `record_fertilization`, both bulk (up to 200 plant ids).
45
+ Reservoir and hydro plants route to a refill/top-up; plants that fertilize with watering also get
46
+ a fertilization logged. Fertilizer and strength default to each plant's own settings.
47
+ - **Health entries** — `add_health_entry`, `update_health_entry`, `resolve_health_entry`
48
+ (reversible in both directions), addressed by entry id.
49
+ - **Recommendations** — `dismiss_care_recommendation`, per occurrence via `fingerprint`.
50
+
51
+ Writes are idempotent per day: a plant already watered or fertilized on the target day is returned
52
+ in a `skipped` list rather than logged twice, so retries never shift the care schedule. Unknown ids
53
+ come back in an `invalid` list instead of failing the whole call.
54
+
55
+ There are no destructive tools. Sales and trades are not exposed, by design.
package/README.md CHANGED
@@ -1,36 +1,117 @@
1
- # plants-mcp
1
+ # @sebamomann/plants-mcp
2
2
 
3
- [MCP](https://modelcontextprotocol.io) server for the **Sprig** plant app. It lets a Claude agent query
4
- your plant collection plants, 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**.
3
+ **Let an AI assistant look after your houseplants.** This is an
4
+ [MCP](https://modelcontextprotocol.io) server that connects Claude or any MCP client to your
5
+ plant collection in **Sprig**, so you can just ask:
8
6
 
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.
7
+ > *"What needs watering today?"*
8
+ > *"Which plants am I behind on?"*
9
+ > *"Log that I watered everything on the windowsill."*
10
+
11
+ ```jsonc
12
+ // Add to your MCP client config, restart it, and you're done.
13
+ {
14
+ "mcpServers": {
15
+ "plants": {
16
+ "command": "npx",
17
+ "args": ["-y", "@sebamomann/plants-mcp"],
18
+ "env": {
19
+ "PLANT_API_URL": "https://your-sprig-instance",
20
+ "PLANT_API_KEY": "sprig_live_..."
21
+ }
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ Full walkthrough in [Setup](#setup). The app also has this guide built in, with your key and
28
+ server address already filled in: **Account → API keys → How to connect an assistant**.
29
+
30
+ ## What is Sprig?
31
+
32
+ Sprig is a web app for keeping a plant collection alive and on schedule. You add your plants with
33
+ their type, location, soil and pot; it tracks watering and fertilizing cycles and tells you what is
34
+ due today, this week, and what you have fallen behind on. Every plant keeps a full history —
35
+ acquisition, propagation, repotting, photos, health notes — and the app derives care
36
+ recommendations from it (chronic lateness, a missed fertilizing season, a plant repotted too
37
+ recently to feed). It also covers soil mixes, fertilizers and pots as your own libraries, plus
38
+ sharing and trading with other growers.
39
+
40
+ This package exposes the care-relevant part of that to an assistant. It is **not** a standalone
41
+ tool: it needs a running Sprig instance and an API key from it. If you don't have one, start at
42
+ the [app repository](https://github.com/sebamomann/plants).
43
+
44
+ ## What this server gives an assistant
45
+
46
+ **23 tools** over your collection: 17 that read it, 6 that log care.
47
+
48
+ Reads cover plants, watering and fertilization history, the full care timeline, photo metadata,
49
+ health entries, the derived care schedule, collection-wide activity, and the lookup catalogs.
50
+ Writes cover recording waterings and fertilizings, health notes, and dismissing care
51
+ recommendations.
52
+
53
+ **Reads need only the `read` scope, which every API key has. Writes need a key created with the
54
+ `write` scope.** There are no destructive tools — nothing here deletes a plant, an event, a photo,
55
+ or a catalog entry. See [Permissions](#permissions--scopes).
56
+
57
+ Every tool is a thin proxy over the app's `/api/v1/*` REST API, authenticated with a per-user API
58
+ key. All results are scoped to that key's owner, and the server holds no logic of its own — which
59
+ is why it stays correct as the app grows.
60
+
61
+ ---
12
62
 
13
63
  ## Setup
14
64
 
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.
65
+ ### 1. Generate an API key
66
+
67
+ In the app: **Account API keys Generate**. Choose:
68
+
69
+ | Key type | Scopes | Can do |
70
+ |---|---|---|
71
+ | **Read-only** | `read` | Every read tool. Write tools return HTTP 403. |
72
+ | **Read & write** | `read`, `write` | Everything, including the 6 care-logging tools. |
73
+
74
+ The key is shown **once** — copy it then. Format: `sprig_live_…`. Pick read-only unless you
75
+ actually want the assistant logging care; you can change a key's access level later without
76
+ re-issuing it.
77
+
78
+ Right after generating, the app offers **How to connect an assistant** that guide hands you the
79
+ config block below with your key and server address already filled in.
80
+
81
+ ### 2. Install
82
+
83
+ Nothing to install by hand — `npx` fetches the package on demand the first time your client starts
84
+ the server. **Requires Node.js 20 or newer.** To check it runs at all:
85
+
86
+ ```sh
87
+ PLANT_API_KEY=sprig_live_... npx @sebamomann/plants-mcp
88
+ ```
89
+
90
+ It should print `[plants-mcp] v… connected.` on stderr and then wait for a client on stdin. That is
91
+ a healthy server, not a hang — press Ctrl-C.
92
+
93
+ To work on it locally instead, clone the app repo — this server lives in it as the `mcp`
94
+ workspace — and build from the repo root:
95
+
96
+ ```sh
97
+ git clone https://github.com/sebamomann/plants.git
98
+ cd plants
99
+ npm install
100
+ npm run mcp:build # -> mcp/dist/index.js
101
+ ```
102
+
103
+ ### 3. Configure
104
+
105
+ Two environment variables (see `.env.example`):
106
+
107
+ | Variable | Required | Default | Description |
108
+ |---|---|---|---|
109
+ | `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. |
30
111
 
31
112
  ## Register with Claude
32
113
 
33
- Add to your MCP client config (e.g. Claude Code / Claude Desktop). Example:
114
+ Add the server to your client's config:
34
115
 
35
116
  ```json
36
117
  {
@@ -39,7 +120,7 @@ Add to your MCP client config (e.g. Claude Code / Claude Desktop). Example:
39
120
  "command": "npx",
40
121
  "args": ["-y", "@sebamomann/plants-mcp"],
41
122
  "env": {
42
- "PLANT_API_URL": "http://localhost:3000",
123
+ "PLANT_API_URL": "https://your-sprig-instance",
43
124
  "PLANT_API_KEY": "sprig_live_..."
44
125
  }
45
126
  }
@@ -47,62 +128,262 @@ Add to your MCP client config (e.g. Claude Code / Claude Desktop). Example:
47
128
  }
48
129
  ```
49
130
 
131
+ Where that config lives:
132
+
133
+ | Client | Location |
134
+ |---|---|
135
+ | **Claude Desktop** | Settings → Developer → Edit Config (`claude_desktop_config.json`) |
136
+ | **Claude Code** | `.mcp.json` in your project folder, or run `claude mcp add` |
137
+
138
+ Then **restart the client**. The tool list is exchanged once at handshake, so a running client will
139
+ not see the server — or newly added tools after an upgrade — until it reconnects.
140
+
141
+ To verify, ask your assistant *"what needs watering today?"*. If the key is wrong you get a 401
142
+ back as readable text rather than a silent failure.
143
+
50
144
  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"]`.
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
+ ---
149
+
150
+ ## Permissions & scopes
151
+
152
+ The scope lives on the API key, is enforced by the app, and cannot be widened from this side.
153
+
154
+ - **`read`** — on every key. Gates all GET endpoints, i.e. all 17 read tools.
155
+ - **`write`** — opt-in when you create the key. Gates every mutating endpoint.
156
+
157
+ What the failure modes look like:
158
+
159
+ | Situation | HTTP | What the assistant sees |
160
+ |---|---|---|
161
+ | Missing, malformed, or revoked key | 401 | The error text, verbatim. Revoking takes effect immediately. |
162
+ | Valid read-only key calling a write tool | 403 | The error text, so it can tell you the key can't write. |
163
+ | Another user's plant id | 404 | Indistinguishable from a nonexistent id — by design. |
164
+
165
+ **Adding a write tool never retroactively widens an existing read-only key.** That is the whole
166
+ point of the scope split: keys created before a write tool existed cannot call it.
167
+
168
+ ### What this server deliberately cannot do
169
+
170
+ Not oversights — deliberate limits:
171
+
172
+ - **No deletes.** No tool removes a plant, event, photo, or catalog entry. The blast radius of a
173
+ misunderstood delete is a multi-year history. Retiring a plant is a *status* change, and both
174
+ resolving a health entry and dismissing a recommendation are reversible.
175
+ - **No sales or trades.** Not readable, not writable, not planned. Those records name a second
176
+ person who never consented to your API key, and the actions are socially irreversible.
177
+ - **No share-link creation.** Minting a public URL for collection data is a decision for the UI.
178
+ - **No cross-user access.** Ownership is enforced per-request from the key; a tool has no way to
179
+ even express "someone else's plant".
180
+
181
+ ---
182
+
183
+ ## Read tools
53
184
 
54
- ## Tools
185
+ ### Identity
55
186
 
56
- | Tool | Endpoint | Purpose |
187
+ | Tool | Endpoint | Arguments |
188
+ |---|---|---|
189
+ | `whoami` | `GET /api/v1/me` | none |
190
+
191
+ ### Plants
192
+
193
+ | Tool | Endpoint | Arguments |
194
+ |---|---|---|
195
+ | `list_plants` | `GET /api/v1/plants` | `status` (`LIVING` \| `DEAD` \| `GIFTED` \| `LOST` \| `SOLD` \| `TRADED` \| `MERGED`), `lifecycle` (`PROPAGATING` \| `ESTABLISHED`), `locationId`, `plantTypeId`, `soilId`, `fertilizerId`, `search` (free text over type name / notes), `limit` (1–200), `offset`, `sort` (e.g. `name`, `createdAt`, `updatedAt`) — all optional |
196
+ | `get_plant` | `GET /api/v1/plants/:id` | `id` **(required)** — full detail: catalogs, care config, lineage, recent event summaries |
197
+
198
+ ### Per-plant history
199
+
200
+ All five take the same arguments: `id` **(required)**, `limit` (1–500), `offset`. Newest first.
201
+
202
+ | Tool | Endpoint | Returns |
57
203
  |---|---|---|
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
204
  | `list_watering_events` | `GET /api/v1/plants/:id/watering-events` | Watering history |
62
205
  | `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 |
206
+ | `list_care_events` | `GET /api/v1/plants/:id/events` | Combined timeline watering, fertilization, refill, hydro, potting, snoozes — each tagged with a `kind` |
207
+ | `list_photos` | `GET /api/v1/plants/:id/photos` | Photo **metadata** (urls, `takenAt`). Not the images themselves. |
208
+ | `list_health_entries` | `GET /api/v1/plants/:id/health` | Health and AI-analysis entries. Source of the `entryId` the health write tools take. |
209
+
210
+ ### Care schedule
211
+
212
+ These return schedule state the **server** derives, using the same helpers the app's own calendar
213
+ uses season, care mode, and snoozes are already applied. Prefer them over recomputing due dates
214
+ from raw event lists. They cover `LIVING` plants only.
215
+
216
+ Shared optional arguments: `date` (`YYYY-MM-DD`, defaults to today), `season` (`summer` \| `winter`,
217
+ overrides the Apr–Sep default), `locationId`.
218
+
219
+ | Tool | Endpoint | Extra arguments | Answers |
220
+ |---|---|---|---|
221
+ | `list_due_care` | `GET /api/v1/care/due` | `windowDays` (0–60, default 0 = that day only), `includeOverdue` (default true) | "What should I do today?" — overdue plus anything due on `date` |
222
+ | `list_overdue_care` | `GET /api/v1/care/overdue` | — | "What have I fallen behind on?" — past-due only, most overdue first, with days late |
223
+ | `get_care_calendar` | `GET /api/v1/care/calendar` | `days` (1–60, default 14) | Day-by-day projection plus an overdue group |
224
+ | `list_care_recommendations` | `GET /api/v1/care/recommendations` | none | Detected problems: watering-cycle mismatch, chronic lateness, missed seasonal fertilizing, recent repotting, stale photos |
225
+
226
+ Three things to know when reading schedule results:
227
+
228
+ - **Days, not timestamps.** `nextDue` and `date` are local `YYYY-MM-DD` strings. Care is
229
+ day-granular, and a UTC timestamp renders as the wrong day east of Greenwich.
230
+ - **`neverLogged` is its own bucket.** A plant with a cycle but no event yet has no anchor, so no
231
+ due date can be computed. Those are returned separately rather than reported as overdue.
232
+ - **Recommendations return i18n message keys plus values**, not rendered prose. Dismissed ones are
233
+ excluded.
234
+
235
+ ### Collection-wide activity
236
+
237
+ | Tool | Endpoint | Arguments |
238
+ |---|---|---|
239
+ | `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` |
240
+
241
+ Use this instead of looping the per-plant tools when the question is about a **time period**
242
+ ("what did I water last week?") rather than one plant. Newest first, each entry tagged with a
243
+ `kind` and naming its plant. Does **not** include acquisitions, gifts, sales, or trades.
244
+
245
+ ### Catalogs
246
+
247
+ For resolving the ids the filters take. No arguments.
248
+
249
+ | Tool | Endpoint |
250
+ |---|---|
251
+ | `list_locations` | `GET /api/v1/locations` |
252
+ | `list_plant_types` | `GET /api/v1/plant-types` |
253
+ | `list_soils` | `GET /api/v1/soils` |
254
+ | `list_fertilizers` | `GET /api/v1/fertilizers` |
255
+
256
+ ---
257
+
258
+ ## Write tools
259
+
260
+ **All six require a `write`-scoped key**; a read-only key gets HTTP 403. Every description starts
261
+ with `WRITE:` so a model cannot mistake one for a read.
262
+
263
+ ### Care logging
264
+
265
+ | Tool | Endpoint | Arguments |
266
+ |---|---|---|
267
+ | `record_watering` | `POST /api/v1/care/watering` | `plantIds` **(required,** 1–200**)**, `wateredAt` (`YYYY-MM-DD`, defaults to now, cannot be in the future) |
268
+ | `record_fertilization` | `POST /api/v1/care/fertilization` | `plantIds` **(required,** 1–200**)**, `fertilizerId`, `fertilizerPercent` (0–1000), `fertilizedAt` |
269
+
270
+ `record_watering` mirrors what the app does on a one-click watering:
271
+
272
+ - **Reservoir and hydro plants** are recorded as a refill / top-up automatically, not as a
273
+ conventional watering.
274
+ - **Plants configured to fertilize with watering** also get a fertilization logged.
275
+
276
+ `record_fertilization` defaults `fertilizerId` and `fertilizerPercent` to **each plant's own
277
+ settings** when omitted, so a bulk call across differently-configured plants still does the right
278
+ thing per plant.
279
+
280
+ ### Health entries
281
+
282
+ | Tool | Endpoint | Arguments |
283
+ |---|---|---|
284
+ | `add_health_entry` | `POST /api/v1/plants/:id/health` | `plantId` **(required)**, `kind` **(required,** `observation` \| `issue`**)**, `text` **(required,** 1–1000 chars**)**, `category` (≤80 chars, e.g. `pests`), `severity` (`low` \| `medium` \| `high`) |
285
+ | `update_health_entry` | `PATCH /api/v1/health-entries/:id` | `entryId` **(required)**, plus any of `kind`, `text`, `category`, `severity` — omitted fields keep their current value |
286
+ | `resolve_health_entry` | `PATCH /api/v1/health-entries/:id` | `entryId` **(required)**, `resolved` (default `true`; pass `false` to reopen) |
287
+
288
+ `kind` is the distinction that matters: `observation` for a neutral note ("new leaf unfurling"),
289
+ `issue` for a problem ("spider mites on undersides").
290
+
291
+ The two edit tools take an **`entryId`, not a `plantId`** — get it from `list_health_entries`. The
292
+ entry id is already unique and ownership-checked, so nesting it under a plant would only add a way
293
+ to get it wrong. Resolving is reversible in both directions.
294
+
295
+ ### Recommendations
296
+
297
+ | Tool | Endpoint | Arguments |
79
298
  |---|---|---|
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` |
299
+ | `dismiss_care_recommendation` | `POST /api/v1/care/recommendations/dismiss` | `plantId` **(required)**, `type` **(required)**, `fingerprint` **(required)** |
300
+
301
+ Pass all three **exactly as returned by `list_care_recommendations`**. The `fingerprint` identifies
302
+ that specific occurrence, so dismissing one never suppresses a later, different recurrence of the
303
+ same problem.
304
+
305
+ Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `noFertilizerThisSeason`,
306
+ `recentlyRepottedAvoidFertilizer`, `noRecentPhoto`.
307
+
308
+ `wateringCycleMismatch` is **not** dismissible — it is a configuration contradiction, fixed by
309
+ editing the plant rather than hidden.
310
+
311
+ ### Write semantics
312
+
313
+ Two guarantees that make retries and bulk calls safe:
314
+
315
+ **Idempotent per day.** A plant already watered or fertilized on the target day is **skipped** and
316
+ returned in a `skipped` list rather than logged twice. This matters more than it sounds: care
317
+ schedules are computed from the newest event, so a duplicate would silently shift every future due
318
+ date. A retried call after a timeout is safe.
86
319
 
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.
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.
90
323
 
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.
324
+ So a successful bulk write is not all-or-nothing: read the `skipped` and `invalid` lists in the
325
+ response, not just the HTTP status.
94
326
 
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.
327
+ ---
97
328
 
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.
329
+ ## Responses and errors
330
+
331
+ Tools return the API's JSON **verbatim**, pretty-printed with 2-space indentation. Nothing is
332
+ reshaped, renamed, or summarised — which is why an added field on an API response needs no new
333
+ version of this package.
334
+
335
+ Failures come back as MCP errors (`isError: true`) with the detail in the text, so the assistant
336
+ can act on them or explain them to you:
337
+
338
+ | Failure | Text |
339
+ |---|---|
340
+ | Network / DNS / connection refused | `Network error calling <path>: <error>` |
341
+ | Non-2xx response | `Request to <path> failed (HTTP <status>): <body>` |
342
+
343
+ A non-JSON body is passed through as raw text rather than being swallowed. If `PLANT_API_KEY` is
344
+ unset the process exits 1 at boot with a message on stderr — the client will report the server as
345
+ failed to start.
346
+
347
+ ---
348
+
349
+ ## Versioning
350
+
351
+ Semver against the **tool surface**, which is this package's public API:
352
+
353
+ | Change | Bump |
354
+ |---|---|
355
+ | New tool, new optional argument | **minor** |
356
+ | Bug fix, description wording, dependency bump | **patch** |
357
+ | Tool removed or renamed, argument removed or made required, env var renamed | **major** |
358
+
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
+ One caveat worth stating plainly: because tools pass the API's JSON straight through, a **response
363
+ shape change in the app can affect you without this package changing version**. Added fields are
364
+ safe; renames and removals are not.
365
+
366
+ ## Development
367
+
368
+ From the repo root:
369
+
370
+ ```sh
371
+ npm run mcp:dev # run from source via tsx, no build step
372
+ npm run mcp:build # tsc -> mcp/dist/
373
+ npm run mcp:typecheck # tsc --noEmit (also part of the root `npm run check`)
374
+ ```
100
375
 
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.
376
+ The whole server is `mcp/src/index.ts`. It needs the app running (default
377
+ `http://localhost:3000`) and a real API key to do anything useful. `mcp/AGENTS.md` documents the
378
+ architecture rules; the canonical spec for the tool surface is `docs/mcp-server.md` at the repo
379
+ root.
104
380
 
105
381
  ## Security
106
382
 
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.
383
+ - The key is sent only to `PLANT_API_URL`, as a bearer token. Keep it secret; treat it like a
384
+ password. It is passed via environment variable, so it never lands in the tool arguments a model
385
+ can see or echo.
386
+ - Prefer a **read-only key** unless you specifically want an assistant logging care.
387
+ - Revoke a key any time from **Account → API Keys**; requests with it start returning 401
388
+ immediately.
389
+ - All data is scoped to the key's owner. Another user's data returns 404.
package/dist/index.js CHANGED
@@ -1,15 +1,24 @@
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. There are no destructive tools.
7
9
  *
8
10
  * Every tool is a thin proxy over the app's `/api/v1/*` REST endpoints,
9
11
  * authenticated with a per-user API key sent as `Authorization: Bearer <key>`.
10
12
  * Tools forward their arguments as query params and return the API's JSON
11
13
  * verbatim, so they stay correct even if the API response shape evolves.
12
14
  */
15
+ /**
16
+ * Single source of truth for the advertised version: package.json, so a release
17
+ * bump cannot leave the MCP handshake reporting a stale number. `../package.json`
18
+ * resolves to the package root both from `dist/index.js` (published) and from
19
+ * `src/index.ts` (tsx dev).
20
+ */
21
+ const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
13
22
  const API_URL = (process.env.PLANT_API_URL ?? "http://localhost:3000").replace(/\/+$/, "");
14
23
  const API_KEY = process.env.PLANT_API_KEY;
15
24
  if (!API_KEY) {
@@ -105,7 +114,7 @@ async function apiSend(method, path, body) {
105
114
  }
106
115
  return { content: [{ type: "text", text: pretty }] };
107
116
  }
108
- const server = new McpServer({ name: "plants-mcp", version: "0.1.0" });
117
+ const server = new McpServer({ name: "plants-mcp", version: VERSION });
109
118
  // --- Identity ---------------------------------------------------------------
110
119
  server.tool("whoami", "Return the authenticated user for the configured API key.", async () => apiGet("/api/v1/me"));
111
120
  // --- Plants -----------------------------------------------------------------
@@ -253,7 +262,7 @@ server.tool("list_fertilizers", "List the user's fertilizers.", async () => apiG
253
262
  async function main() {
254
263
  const transport = new StdioServerTransport();
255
264
  await server.connect(transport);
256
- console.error(`[plants-mcp] connected. API base: ${API_URL}`);
265
+ console.error(`[plants-mcp] v${VERSION} connected. API base: ${API_URL}`);
257
266
  }
258
267
  main().catch((err) => {
259
268
  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.0.0",
4
+ "description": "MCP server for the Sprig plant app: 23 tools to read a plant collection and log care. Read-only by default; waterings, fertilizings, and health notes need a write-scoped API key. No destructive tools.",
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"
@@ -39,12 +46,11 @@
39
46
  "prepublishOnly": "npm run build"
40
47
  },
41
48
  "dependencies": {
42
- "@modelcontextprotocol/sdk": "^1.12.0",
43
- "zod": "^3.24.1"
49
+ "@modelcontextprotocol/sdk": "^1.29.0",
50
+ "zod": "^4.4.3"
44
51
  },
45
52
  "devDependencies": {
46
- "@types/node": "^22.10.0",
47
- "tsx": "^4.19.2",
48
- "typescript": "^5.7.2"
53
+ "@types/node": "^26.1.1",
54
+ "typescript": "^6.0.3"
49
55
  }
50
56
  }