@sebamomann/plants-mcp 2.13.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,81 @@
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
+ ## 3.1.0 — 2026-10-19
7
+
8
+ **New tool `mark_watering_due`.** Tool count 82 (was 81). A **minor** bump:
9
+
10
+ - `mark_watering_due` (`POST /api/v1/care/due-today`, `plantId`) is the "needs water" answer to an
11
+ autumn "Prüfen" check: it records no watering but marks the plant due today, so it appears in
12
+ `list_due_care` `tasks`. Cleared by the next watering; replaced by `dismiss_transition_check` or a
13
+ snooze; if not acted on the plant reads as overdue from the next day.
14
+ - Additive: care-snooze timeline entries carry `reason` (`dueToday` for these marks, `stillWet` for
15
+ "noch feucht" skips, otherwise `null`).
16
+
17
+ ## 3.0.0 — 2026-10-04
18
+
19
+ **`noFertilizerThisSeason` is renamed `fertilizingNotStarted`.** Tool count unchanged (81). A **major**
20
+ bump — an accepted argument value was removed:
21
+
22
+ - `list_care_recommendations` no longer returns `noFertilizerThisSeason` ("no feeding since the
23
+ season started", which fired on day one of every season). The new type `fertilizingNotStarted`
24
+ fires only for a plant with a fertilizer and an active fertilizing cycle that has **never** been
25
+ fertilized, once a full cycle has passed since feeding could have started (acquisition, the end of
26
+ a repotting rest, or the end of a winter pause). Cuttings are excluded.
27
+ - `dismiss_care_recommendation`'s `type` enum: `noFertilizerThisSeason` → `fertilizingNotStarted`.
28
+ Old dismissals of the removed type no longer apply to anything.
29
+ - Additive: `wateringCycleMismatch.values` gains `season` (`"summer"`/`"winter"`), the column the
30
+ suggestion is expressed in. The mismatch is now judged against the cycle in effect when each
31
+ interval started (season, gradual season ramp, controlled climate), so a season change alone no
32
+ longer makes a plant look off-cycle.
33
+ - `list_notifications`: a `CHANGELOG_PUBLISHED` row may now carry `messageKey:
34
+ "changelogPublishedSummary"` with `values: { summary }` (that day's summary) instead of
35
+ `changelogPublished` with `{ count }`.
36
+
37
+ ## 2.16.0 — 2026-10-04
38
+
39
+ **Autumn "Prüfen" checks: `list_due_care` gains `checks`, new `dismiss_transition_check`.** Tool count
40
+ 81 (was 80). A **minor** bump — one new tool and one additive response field:
41
+
42
+ - `list_due_care` returns an additive `checks` array — `{ plantId, name, lastWateredAt, checkSince,
43
+ regularDueAt }` — for plants whose watering interval grew with the autumn season change while the
44
+ plant may dry out at its summer pace. A question, not a task: not in `tasks`/`counts`. Existing
45
+ fields are unchanged.
46
+ - New write tool `dismiss_transition_check` (`plantId`): answers a check with "noch feucht", hiding
47
+ it until its regular due date without moving that date. The other answer ("dry") is
48
+ `record_watering`.
49
+
50
+ ## 2.15.0 — 2026-10-04
51
+
52
+ **`update_plant_care` gains `keepWinterCare`; `get_plant` reports the effective schedule.** Tool count
53
+ unchanged (80). A **minor** bump — one new optional argument and new response fields:
54
+
55
+ - `update_plant_care` accepts `keepWinterCare: boolean`. A plant in a controlled-climate location
56
+ follows its summer watering and fertilizing cycle all year; `keepWinterCare: true` brings the
57
+ winter cycle back for both. The stored winter values are never cleared by it.
58
+ - `get_plant` returns `keepWinterCare`, the location's `controlledClimate` (inside `location`) and a
59
+ new top-level `followsSummerYearRound` boolean.
60
+ - **Behaviour change in `get_plant`:** its six schedule fields now show what scheduling actually uses.
61
+ For a plant that follows its summer cycle all year, the winter fields
62
+ (`wateringFrequencyWinterDays`, `wateringFrequencyWinter`, `fertilizingCycleWinterWeeks`) are the
63
+ *effective* (summer) values, not the stored winter ones. Nothing changes for plants outside a
64
+ controlled-climate location.
65
+ - `update_plant_care`'s result includes `keepWinterCare`.
66
+
67
+ ## 2.14.0 — 2026-10-04
68
+
69
+ **New tool: `update_location`; `create_location` gains `controlledClimate`.** Tool count 79 → 80
70
+ (28 read, 34 write, 18 admin). A **minor** bump — one new write tool and one new optional argument:
71
+
72
+ - `PATCH /api/v1/locations/:id` takes any of `name`, `parentId` (`null` = top level) and
73
+ `controlledClimate`; only the fields present change. Shares its core with the location detail
74
+ page's save action, so cycle checks on `parentId` behave identically.
75
+ - `controlledClimate` marks a place with constant light and warmth (e.g. a plant cabinet): plants
76
+ placed directly there follow their summer watering and fertilizing cycle all year, unless the
77
+ plant opts back into winter care. `create_location` accepts it too.
78
+ - `list_locations` now returns `controlledClimate` per location (a new response field — no
79
+ change needed by callers).
80
+
6
81
  ## 2.13.0 — 2026-09-30
7
82
 
8
83
  **New tool: `merge_soils`.** Tool count 78 → 79 (28 read, 33 write, 18 admin). A **minor** bump —
package/README.md CHANGED
@@ -8,7 +8,14 @@ just ask:
8
8
  > *"Which plants am I behind on?"*
9
9
  > *"Log that I watered everything on the windowsill."*
10
10
 
11
- ## Quick start
11
+ > **Using Claude (claude.ai, the mobile app, Claude Desktop or Claude Code)?** Connect to the
12
+ > remote endpoint instead and sign in, no key to copy: add a custom connector with the URL
13
+ > `https://plants.sebamomann.de/api/mcp`, leave the OAuth fields empty and sign in to Sprig
14
+ > (in Claude Code: `claude mcp add --transport http sprig https://plants.sebamomann.de/api/mcp`,
15
+ > then `/mcp`). This package stays for **local stdio setups** with an API key, and API keys also
16
+ > work as a header against the remote endpoint for scripts.
17
+
18
+ ## Quick start (local, with an API key)
12
19
 
13
20
  **1. Get an API key.** In Sprig: **Account → Settings → API keys → Generate**. It is shown once — copy it
14
21
  then. Pick read-only unless you actually want the assistant logging care; you can change a key's
@@ -60,7 +67,7 @@ needs a running Sprig instance and an API key from it. Start at the
60
67
 
61
68
  ## What an assistant can do
62
69
 
63
- **79 tools** over your collection: 28 that read it, 33 that write to it. **18 admin tools** are also
70
+ **82 tools** over your collection: 28 that read it, 36 that write to it. **18 admin tools** are also
64
71
  available — but only to a key created by an admin — for finding and understanding duplicates in the
65
72
  shared global plant type catalog, restructuring its genus/species/cultivar tree, reading, directly
66
73
  editing, merging, and reviewing proposals and duplicate candidates in that catalog, and (7 read-only,
@@ -112,7 +119,7 @@ healthy server, not a hang — press Ctrl-C.
112
119
  The scope lives on the API key, is enforced by the app, and cannot be widened from this side.
113
120
 
114
121
  - **`read`** — on every key. Gates all 28 read tools.
115
- - **`write`** — opt-in when you create the key. Gates the 33 write tools.
122
+ - **`write`** — opt-in when you create the key. Gates the 36 write tools.
116
123
  - **`admin`** — only offered when the key's creator is themselves an admin, and only while they
117
124
  still are one (the app re-checks this on every admin-tool call, not just at key creation). Gates
118
125
  the 18 admin catalog tools — 7 read-only, 11 that write — see **Admin tools** below. `admin` alone
@@ -174,7 +181,13 @@ facts (light, water, feeding need, toxicity, climate, growth, substrate/propagat
174
181
  region), keyed by the same field names `get_plant_type` uses — and its six schedule fields
175
182
  (`wateringFrequencySummerDays`/`WinterDays`, `wateringFrequencySummer`/`Winter`,
176
183
  `fertilizingCycleSummerWeeks`/`WinterWeeks`) as `{ value, source: "plant"|"type"|"none", typeValue }`
177
- instead of a raw value — see **Breaking changes in 2.0.0** below.
184
+ instead of a raw value — see **Breaking changes in 2.0.0** below. They show what scheduling actually
185
+ uses: for a plant in a controlled-climate location without `keepWinterCare`, the winter fields equal the
186
+ summer values, and `followsSummerYearRound` is `true` (also returned: `keepWinterCare` and
187
+ `location.controlledClimate`). `inWinterRest` is `true` while the plant is in its winter rest (its type's
188
+ `winterRest` fact is set, it is winter — roughly late October to mid-March — and it does not follow its summer
189
+ cycle year-round): such a plant drops leaves and wants almost no water or fertilizer on purpose, so don't treat
190
+ it as declining.
178
191
 
179
192
  ### Per-plant history
180
193
 
@@ -200,10 +213,10 @@ overrides the Apr–Sep default), `locationId`.
200
213
 
201
214
  | Tool | Endpoint | Extra arguments | Answers |
202
215
  |---|---|---|---|
203
- | `list_due_care` | `GET /api/v1/care/due` | `windowDays` (0–60, default 0 = that day only), `includeOverdue` (default true) | "What should I do today?" |
216
+ | `list_due_care` | `GET /api/v1/care/due` | `windowDays` (0–60, default 0 = that day only), `includeOverdue` (default true) | "What should I do today?" Also carries the additive `checks` array (autumn "Prüfen" questions — see `dismiss_transition_check`). |
204
217
  | `list_overdue_care` | `GET /api/v1/care/overdue` | — | "What have I fallen behind on?" — most overdue first, with days late |
205
218
  | `get_care_calendar` | `GET /api/v1/care/calendar` | `days` (1–60, default 14) | Day-by-day projection plus an overdue group |
206
- | `list_care_recommendations` | `GET /api/v1/care/recommendations` | none | Detected problems: cycle mismatch, chronic lateness, missed seasonal fertilizing, fertilizer pause after repotting, repotting overdue, stale photos |
219
+ | `list_care_recommendations` | `GET /api/v1/care/recommendations` | none | Detected problems: cycle mismatch, chronic lateness, fertilizing never started, fertilizer pause after repotting, repotting overdue, stale photos |
207
220
 
208
221
  Five things to know when reading schedule results:
209
222
 
@@ -215,7 +228,8 @@ Five things to know when reading schedule results:
215
228
  they never appear in `tasks`, `days[]` or `neverLogged` at all. Instead, `list_due_care` and
216
229
  `get_care_calendar` each carry a `reservoirChecks` array of `{ plantId, nextCheckAt,
217
230
  daysUntilCheck, confidence }` — a *predicted* next check, the median gap between that plant's own
218
- last few refills (reservoir) or hydro entries, not a schedule the user configured. `nextCheckAt` is
231
+ last few refills (reservoir) or hydro entries, checked ~15% of it early (at least a day), not a
232
+ schedule the user configured. `nextCheckAt` is
219
233
  a `YYYY-MM-DD` day like `nextDue`; `daysUntilCheck` is negative once it has passed; `confidence` is
220
234
  `"rough"` below three observed gaps and `"established"` from there on. The array ignores
221
235
  `windowDays`/`days` — every plant with a prediction is always in it — and a plant with fewer than
@@ -362,7 +376,7 @@ tools** → **Catalogs** below) lets a write-scoped key add it.
362
376
 
363
377
  ## Write tools
364
378
 
365
- **All 31 ordinary write tools require a `write`-scoped key**; a read-only key gets HTTP 403. Every
379
+ **All 32 ordinary write tools require a `write`-scoped key**; a read-only key gets HTTP 403. Every
366
380
  description starts with `WRITE:` so a model cannot mistake one for a read.
367
381
 
368
382
  ### Care logging
@@ -419,7 +433,7 @@ it is — that backlog is not the watering's doing.
419
433
 
420
434
  | Tool | Endpoint | Arguments |
421
435
  |---|---|---|
422
- | `update_plant_care` | `PATCH /api/v1/plants/:id/care` | `plantId` **(required)**, plus any of `wateringMode` (`scheduled`\|`reservoir`\|`hydro`), `fertilizingMode` (`scheduled`\|`with_watering`), `wateringFrequencySummer`/`wateringFrequencyWinter` (labels), `wateringFrequencySummerDays`/`wateringFrequencyWinterDays` (1–365), `wateringNotes`, `fertilizingCycleSummerWeeks`/`fertilizingCycleWinterWeeks` (0–52), `fertilizerPercent` (0–1000), `fertilizingNotes` |
436
+ | `update_plant_care` | `PATCH /api/v1/plants/:id/care` | `plantId` **(required)**, plus any of `wateringMode` (`scheduled`\|`reservoir`\|`hydro`), `fertilizingMode` (`scheduled`\|`with_watering`), `wateringFrequencySummer`/`wateringFrequencyWinter` (labels), `wateringFrequencySummerDays`/`wateringFrequencyWinterDays` (1–365), `wateringNotes`, `fertilizingCycleSummerWeeks`/`fertilizingCycleWinterWeeks` (0–52), `fertilizerPercent` (0–1000), `fertilizingNotes`, `keepWinterCare` (boolean — winter cycle despite a controlled-climate location) |
423
437
  | `update_plant` | `PATCH /api/v1/plants/:id` | `plantId` **(required)**, plus any of `plantTypeId`, `nickname`, `locationId`, `soilId`, `fertilizerId`, `quantity` (0–9999), `notes`, `tags` (replaces the full list), `sitterInstructions`, `parentPlantId` |
424
438
 
425
439
  Both take a `plantId` and edit **only the fields you pass** — an omitted field keeps its current
@@ -529,11 +543,28 @@ The two edit tools take an **`entryId`, not a `plantId`** — get it from `list_
529
543
  | Tool | Endpoint | Arguments |
530
544
  |---|---|---|
531
545
  | `dismiss_care_recommendation` | `POST /api/v1/care/recommendations/dismiss` | `plantId`, `type`, `fingerprint` — **all required** |
546
+ | `dismiss_transition_check` | `POST /api/v1/care/transition-checks/dismiss` | `plantId` **(required)** — a plant listed in `checks` of `list_due_care` |
547
+ | `mark_watering_due` | `POST /api/v1/care/due-today` | `plantId` **(required)** — marks the plant's watering due today without recording one |
548
+
549
+ `mark_watering_due` is the "needs water" answer to an autumn "Prüfen" check (and works for any plant
550
+ that looks thirsty): it records **no** watering, it pulls the plant's due date forward to today, so
551
+ the plant shows up in `list_due_care` `tasks` (and in the app's care plan, counts and reminders)
552
+ with the usual answers. The next watering clears the mark; a later `dismiss_transition_check` or
553
+ snooze replaces it; if nobody acts, the plant reads as overdue from the next day. Idempotent while
554
+ the mark stands. Returns `{ plantId, dueOn }`.
555
+
556
+ `dismiss_transition_check` answers an autumn "Prüfen" check with "noch feucht": as the season changes
557
+ to winter the watering interval grows, so a plant that dries out at its summer pace would go
558
+ unreminded for longer. `list_due_care` lists such plants in `checks`
559
+ (`{ plantId, name, lastWateredAt, checkSince, regularDueAt }`) from the day the summer-pace watering
560
+ would have been due until the regular due date. The dismissal hides the check until then and never
561
+ moves the due date; a new watering reopens the question. The other answer, "dry", is a plain
562
+ `record_watering`.
532
563
 
533
564
  Pass all three **exactly as returned by `list_care_recommendations`**. The `fingerprint` identifies
534
565
  that specific occurrence, so dismissing one never suppresses a later, different recurrence.
535
566
 
536
- Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `noFertilizerThisSeason`,
567
+ Dismissible `type` values: `wateringOftenLate`, `fertilizingOftenLate`, `fertilizingNotStarted`,
537
568
  `recentlyRepottedAvoidFertilizer`, `noRecentPhoto`, `propagationCheckDue`, `possibleDecline`.
538
569
  `wateringCycleMismatch` is **not** dismissible — it is a configuration contradiction, fixed by
539
570
  editing the plant rather than hidden.
@@ -593,7 +624,8 @@ mirror (opening the page already marks everything read).
593
624
 
594
625
  | Tool | Endpoint | Arguments |
595
626
  |---|---|---|
596
- | `create_location` | `POST /api/v1/locations` | `name` **(required)**, `parentId` (an owned location, to nest under) |
627
+ | `create_location` | `POST /api/v1/locations` | `name` **(required)**, `parentId` (an owned location, to nest under), `controlledClimate` (plants there follow their summer watering/fertilizing cycle year-round unless they opt back into winter care) |
628
+ | `update_location` | `PATCH /api/v1/locations/:id` | `locationId` **(required)**, `name`, `parentId` (`null` = top level), `controlledClimate` — only the fields passed change |
597
629
  | `create_soil` | `POST /api/v1/soils` | `name` **(required)**, `notes`, `items` (`{ componentName, parts }[]`, each component upserted by name) |
598
630
  | `merge_soils` | `POST /api/v1/soils/merge` | `sourceId` **(required)**, `targetId` **(required)** |
599
631
  | `create_fertilizer` | `POST /api/v1/fertilizers` | `name` **(required)**, `npk`, `baseDosePerLiterMl` (0–100) |
@@ -846,7 +878,7 @@ npm run mcp:typecheck # also part of the root `npm run check`
846
878
  To point a client at your checkout, use `"command": "node", "args": ["/…/plants/mcp/dist/index.js"]`
847
879
  — or skip the build with `"command": "npx", "args": ["tsx", "/…/plants/mcp/src/index.ts"]`.
848
880
 
849
- The whole server is `mcp/src/index.ts`. `mcp/AGENTS.md` has the architecture rules; the canonical
881
+ The tools live in `app/_lib/mcp/tools.ts`; `mcp/src/index.ts` is only the stdio wrapper. `mcp/AGENTS.md` has the architecture rules; the canonical
850
882
  spec for the tool surface is `docs/mcp-server.md` at the repo root.
851
883
 
852
884
  ## Security
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The shared HTTP proxy every tool in `tools.ts` is built on: `apiGet` for reads,
3
+ * `apiSend` for writes. Kept in its own module so it can be unit-tested in isolation,
4
+ * away from the `McpServer` and transport wiring.
5
+ *
6
+ * Both functions take the API base URL and key as a config object rather than
7
+ * reading them from `process.env` themselves, so a test can point them at a mocked
8
+ * `fetch` with fixture values, and a transport can build one client per request.
9
+ *
10
+ * No `@/` alias and nothing Next-specific here: the stdio build compiles this file too.
11
+ */
12
+ export function createApiClient({ apiUrl, apiKey }) {
13
+ async function apiGet(path, query = {}) {
14
+ const url = new URL(`${apiUrl}${path}`);
15
+ for (const [key, value] of Object.entries(query)) {
16
+ if (value !== undefined && value !== "")
17
+ url.searchParams.set(key, String(value));
18
+ }
19
+ let res;
20
+ try {
21
+ res = await fetch(url, {
22
+ headers: {
23
+ Authorization: `Bearer ${apiKey}`,
24
+ Accept: "application/json",
25
+ },
26
+ });
27
+ }
28
+ catch (err) {
29
+ return {
30
+ content: [{ type: "text", text: `Network error calling ${url.pathname}: ${String(err)}` }],
31
+ isError: true,
32
+ };
33
+ }
34
+ const body = await res.text();
35
+ if (!res.ok) {
36
+ return {
37
+ content: [
38
+ {
39
+ type: "text",
40
+ text: `Request to ${url.pathname} failed (HTTP ${res.status}): ${body || res.statusText}`,
41
+ },
42
+ ],
43
+ isError: true,
44
+ };
45
+ }
46
+ // Pretty-print JSON when possible, otherwise return raw text.
47
+ let text = body;
48
+ try {
49
+ text = JSON.stringify(JSON.parse(body), null, 2);
50
+ }
51
+ catch {
52
+ // leave as-is
53
+ }
54
+ return { content: [{ type: "text", text }] };
55
+ }
56
+ /**
57
+ * Sends a JSON body to a mutating endpoint. Requires an API key with the
58
+ * `write` scope — a read-only key gets a 403 surfaced back to the model verbatim
59
+ * so it can tell the user their key can't write. `DELETE` sends no body.
60
+ */
61
+ async function apiSend(method, path, body) {
62
+ const url = new URL(`${apiUrl}${path}`);
63
+ let res;
64
+ try {
65
+ res = await fetch(url, {
66
+ method,
67
+ headers: {
68
+ Authorization: `Bearer ${apiKey}`,
69
+ Accept: "application/json",
70
+ ...(body ? { "Content-Type": "application/json" } : {}),
71
+ },
72
+ ...(body ? { body: JSON.stringify(body) } : {}),
73
+ });
74
+ }
75
+ catch (err) {
76
+ return {
77
+ content: [{ type: "text", text: `Network error calling ${url.pathname}: ${String(err)}` }],
78
+ isError: true,
79
+ };
80
+ }
81
+ const text = await res.text();
82
+ if (!res.ok) {
83
+ return {
84
+ content: [
85
+ {
86
+ type: "text",
87
+ text: `Request to ${url.pathname} failed (HTTP ${res.status}): ${text || res.statusText}`,
88
+ },
89
+ ],
90
+ isError: true,
91
+ };
92
+ }
93
+ let pretty = text;
94
+ try {
95
+ pretty = JSON.stringify(JSON.parse(text), null, 2);
96
+ }
97
+ catch {
98
+ // leave as-is
99
+ }
100
+ return { content: [{ type: "text", text: pretty }] };
101
+ }
102
+ return { apiGet, apiSend };
103
+ }