@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 +11 -0
- package/README.md +25 -6
- package/dist/app/_lib/mcp/apiClient.js +103 -0
- package/dist/app/_lib/mcp/tools.js +573 -0
- package/dist/app/_lib/mcp/version.js +7 -0
- package/dist/index.js +1 -605
- package/dist/mcp/package.json +57 -0
- package/dist/mcp/src/index.js +36 -0
- package/package.json +4 -7
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
|
-
|
|
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
|
-
**
|
|
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
|
|
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,
|
|
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
|
|
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
|
+
}
|