@sebamomann/plants-mcp 3.0.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,17 @@
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
+
6
17
  ## 3.0.0 — 2026-10-04
7
18
 
8
19
  **`noFertilizerThisSeason` is renamed `fertilizingNotStarted`.** Tool count unchanged (81). A **major**
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
- **81 tools** over your collection: 28 that read it, 35 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 35 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
@@ -177,7 +184,10 @@ region), keyed by the same field names `get_plant_type` uses — and its six sch
177
184
  instead of a raw value — see **Breaking changes in 2.0.0** below. They show what scheduling actually
178
185
  uses: for a plant in a controlled-climate location without `keepWinterCare`, the winter fields equal the
179
186
  summer values, and `followsSummerYearRound` is `true` (also returned: `keepWinterCare` and
180
- `location.controlledClimate`).
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.
181
191
 
182
192
  ### Per-plant history
183
193
 
@@ -218,7 +228,8 @@ Five things to know when reading schedule results:
218
228
  they never appear in `tasks`, `days[]` or `neverLogged` at all. Instead, `list_due_care` and
219
229
  `get_care_calendar` each carry a `reservoirChecks` array of `{ plantId, nextCheckAt,
220
230
  daysUntilCheck, confidence }` — a *predicted* next check, the median gap between that plant's own
221
- 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
222
233
  a `YYYY-MM-DD` day like `nextDue`; `daysUntilCheck` is negative once it has passed; `confidence` is
223
234
  `"rough"` below three observed gaps and `"established"` from there on. The array ignores
224
235
  `windowDays`/`days` — every plant with a prediction is always in it — and a plant with fewer than
@@ -533,6 +544,14 @@ The two edit tools take an **`entryId`, not a `plantId`** — get it from `list_
533
544
  |---|---|---|
534
545
  | `dismiss_care_recommendation` | `POST /api/v1/care/recommendations/dismiss` | `plantId`, `type`, `fingerprint` — **all required** |
535
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 }`.
536
555
 
537
556
  `dismiss_transition_check` answers an autumn "Prüfen" check with "noch feucht": as the season changes
538
557
  to winter the watering interval grows, so a plant that dries out at its summer pace would go
@@ -859,7 +878,7 @@ npm run mcp:typecheck # also part of the root `npm run check`
859
878
  To point a client at your checkout, use `"command": "node", "args": ["/…/plants/mcp/dist/index.js"]`
860
879
  — or skip the build with `"command": "npx", "args": ["tsx", "/…/plants/mcp/src/index.ts"]`.
861
880
 
862
- 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
863
882
  spec for the tool surface is `docs/mcp-server.md` at the repo root.
864
883
 
865
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
+ }