@sebamomann/plants-mcp 2.5.0 → 2.7.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 +38 -0
- package/README.md +29 -11
- package/dist/apiClient.js +103 -0
- package/dist/index.js +8 -92
- package/package.json +9 -6
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,44 @@
|
|
|
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
|
+
## 2.7.0 — 2026-09-24
|
|
7
|
+
|
|
8
|
+
**Nudges.** 77 tools registered overall (28 read, 31 write, 18 admin). A **minor** bump — one new
|
|
9
|
+
tool, nothing renamed or removed:
|
|
10
|
+
|
|
11
|
+
- `list_nudges` — the caller's whole ranked "gaps worth mentioning" queue: stale or missing photos,
|
|
12
|
+
plants without a type or location, long-unresolved health entries, and app features never tried.
|
|
13
|
+
Each entry is `{ kind, variant, count, href }`; the app itself only ever shows the first one, but
|
|
14
|
+
an assistant sees the whole queue. Read-only — snoozing a nudge stays an in-app action, deliberately;
|
|
15
|
+
see `docs/nudges.md`'s "MCP" section and `docs/mcp-server.md`'s roadmap for why `snooze_nudge` isn't
|
|
16
|
+
part of this.
|
|
17
|
+
|
|
18
|
+
## 2.6.0 — 2026-09-24
|
|
19
|
+
|
|
20
|
+
**Snooze until a date.** Tool count unchanged (76 overall). A **minor** bump — one new optional
|
|
21
|
+
argument, nothing renamed or removed: `snooze_care` takes `snoozedUntil` (`YYYY-MM-DD`, after today,
|
|
22
|
+
within a year) as an alternative to `durationDays`, which is now optional. Exactly one of the two is
|
|
23
|
+
required; passing both or neither is a 400. The app's own snooze picker gained the same date field.
|
|
24
|
+
Behaviour change without a shape change: a `durationDays` preset now counts from today once
|
|
25
|
+
`originalDueAt` has passed (it used to count from `originalDueAt` regardless, so "3 days" on a
|
|
26
|
+
week-overdue plant landed four days in the past and deferred nothing).
|
|
27
|
+
|
|
28
|
+
## 2.5.2 — 2026-09-24
|
|
29
|
+
|
|
30
|
+
**Dependency refresh.** Tool count unchanged (76 overall). A **patch** bump — nothing renamed or
|
|
31
|
+
removed: `@modelcontextprotocol/sdk` 1.29 → 1.30 and `zod` 4.4 → 4.6; the workspace's tests run
|
|
32
|
+
on Vitest 5.
|
|
33
|
+
|
|
34
|
+
## 2.5.1 — 2026-09-20
|
|
35
|
+
|
|
36
|
+
**Richer admin dashboard status.** Tool count unchanged (76 overall). A **patch** bump — the
|
|
37
|
+
`admin_get_dashboard_status` response gains four optional groups, nothing renamed or removed:
|
|
38
|
+
|
|
39
|
+
- `growth`, `community`, `trade`, `content` — accounts by status and care-active users, community
|
|
40
|
+
activity per week and open reports with age, aggregate deal/offer counts (never money or parties),
|
|
41
|
+
and photo/plant/analysis/notification totals. Each is independently `{ available: false }` when
|
|
42
|
+
its reads failed. See `docs/mcp-server.md`'s "Admin dashboard status".
|
|
43
|
+
|
|
6
44
|
## 2.5.0 — 2026-09-16
|
|
7
45
|
|
|
8
46
|
**Past problem diagnoses.** 76 tools registered overall (27 read, 31 write, 18 admin). A **minor**
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ just ask:
|
|
|
10
10
|
|
|
11
11
|
## Quick start
|
|
12
12
|
|
|
13
|
-
**1. Get an API key.** In Sprig: **Account → API keys → Generate**. It is shown once — copy it
|
|
13
|
+
**1. Get an API key.** In Sprig: **Account → Settings → API keys → Generate**. It is shown once — copy it
|
|
14
14
|
then. Pick read-only unless you actually want the assistant logging care; you can change a key's
|
|
15
15
|
access level later without re-issuing it.
|
|
16
16
|
|
|
@@ -46,7 +46,7 @@ Requires **Node.js 20 or newer**. Nothing to install by hand: `npx` fetches the
|
|
|
46
46
|
time your client starts the server.
|
|
47
47
|
|
|
48
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**.
|
|
49
|
+
> **Account → Settings → API keys → How to connect an assistant**.
|
|
50
50
|
|
|
51
51
|
## What is Sprig?
|
|
52
52
|
|
|
@@ -60,7 +60,7 @@ needs a running Sprig instance and an API key from it. Start at the
|
|
|
60
60
|
|
|
61
61
|
## What an assistant can do
|
|
62
62
|
|
|
63
|
-
**
|
|
63
|
+
**77 tools** over your collection: 28 that read it, 31 that write to it. **18 admin tools** are also
|
|
64
64
|
available — but only to a key created by an admin — for finding and understanding duplicates in the
|
|
65
65
|
shared global plant type catalog, restructuring its genus/species/cultivar tree, reading, directly
|
|
66
66
|
editing, merging, and reviewing proposals and duplicate candidates in that catalog, and (7 read-only,
|
|
@@ -68,8 +68,9 @@ editing, merging, and reviewing proposals and duplicate candidates in that catal
|
|
|
68
68
|
|
|
69
69
|
Reads cover plants, the shared global plant type catalog, watering and fertilization history, the
|
|
70
70
|
full care timeline, photo metadata, health entries, the derived care schedule, collection-wide
|
|
71
|
-
activity,
|
|
72
|
-
(including pots). Writes cover recording
|
|
71
|
+
activity, nudges (gaps in the collection worth mentioning), the wishlist, watched types,
|
|
72
|
+
notifications, vacation status, and the lookup catalogs (including pots). Writes cover recording
|
|
73
|
+
waterings, fertilizings, repottings, reservoir refills and
|
|
73
74
|
hydro events, snoozing a due date, health notes, dismissing care recommendations, editing a plant's
|
|
74
75
|
identity/care schedule/settings/lifecycle status, adding a plant (identified or not), propagating
|
|
75
76
|
one, merging plants together and reversing that merge, deleting a watering or fertilization event,
|
|
@@ -109,7 +110,7 @@ healthy server, not a hang — press Ctrl-C.
|
|
|
109
110
|
|
|
110
111
|
The scope lives on the API key, is enforced by the app, and cannot be widened from this side.
|
|
111
112
|
|
|
112
|
-
- **`read`** — on every key. Gates all
|
|
113
|
+
- **`read`** — on every key. Gates all 28 read tools.
|
|
113
114
|
- **`write`** — opt-in when you create the key. Gates the 31 write tools.
|
|
114
115
|
- **`admin`** — only offered when the key's creator is themselves an admin, and only while they
|
|
115
116
|
still are one (the app re-checks this on every admin-tool call, not just at key creation). Gates
|
|
@@ -247,6 +248,20 @@ vacation in **either** mode (`sitter` or `shift`), with just `{ id, mode, starts
|
|
|
247
248
|
isUpcoming, plantCount }` — no per-plant detail or schedule. Use `sitter_briefing` for the full
|
|
248
249
|
sitter sheet; use this one just to know whether a vacation (of either mode) exists at all.
|
|
249
250
|
|
|
251
|
+
### Nudges
|
|
252
|
+
|
|
253
|
+
| Tool | Endpoint | Arguments |
|
|
254
|
+
|---|---|---|
|
|
255
|
+
| `list_nudges` | `GET /api/v1/nudges` | none |
|
|
256
|
+
|
|
257
|
+
Gaps in the collection worth mentioning, distinct from due/overdue care: stale or missing photos,
|
|
258
|
+
plants without a type or location, long-unresolved health entries, and app features the caller has
|
|
259
|
+
never tried. Returns the caller's whole ranked queue as `{ kind, variant, count, href }[]` — the app
|
|
260
|
+
itself only ever shows the first entry, but an assistant benefits from seeing everything pending.
|
|
261
|
+
`count` is an aggregate number for that kind (`0` for a feature-discovery entry); `href` is the
|
|
262
|
+
in-app page to send the user to. An empty list means nothing is currently worth nudging about.
|
|
263
|
+
Read-only — snoozing a nudge stays an in-app action by design.
|
|
264
|
+
|
|
250
265
|
### Wishlist
|
|
251
266
|
|
|
252
267
|
| Tool | Endpoint | Arguments |
|
|
@@ -336,7 +351,7 @@ description starts with `WRITE:` so a model cannot mistake one for a read.
|
|
|
336
351
|
| `record_repotting` | `POST /api/v1/plants/:id/repotting` | `plantId`, `potId` **(both required)**, plus `potSizeId`, `soilId`, `pottedAt`, `hasDrainage`, `hasClimbingAid`, `notes` |
|
|
337
352
|
| `record_refill` | `POST /api/v1/plants/:id/refill` | `plantId` **(required)**, plus `fertilizerId`, `fertilizerPercent`, `refilledAt` |
|
|
338
353
|
| `record_hydro_event` | `POST /api/v1/plants/:id/hydro` | `plantId` **(required)**, plus `kind` (`topup`\|`change`), `waterLevel` (0–100), `fertilizerId`, `fertilizerPercent`, `recordedAt`, `notes` |
|
|
339
|
-
| `snooze_care` | `POST /api/v1/care/snooze` | `plantId`, `careType` (`water`\|`fert`), `durationDays` (1, 3, 7, or 14)
|
|
354
|
+
| `snooze_care` | `POST /api/v1/care/snooze` | `plantId`, `careType` (`water`\|`fert`), `originalDueAt` **(all required)**, exactly one of `durationDays` (1, 3, 7, or 14) or `snoozedUntil` (`YYYY-MM-DD`), `note` |
|
|
340
355
|
|
|
341
356
|
`record_watering` mirrors a one-click watering in the app: reservoir and hydro plants are recorded
|
|
342
357
|
as a refill / top-up automatically, and plants configured to fertilize with watering also get a
|
|
@@ -360,7 +375,10 @@ hydro-cultured plant.
|
|
|
360
375
|
|
|
361
376
|
`snooze_care` postpones a plant's next watering or fertilizing due date — the same picker the care
|
|
362
377
|
hub offers. `originalDueAt` is the due date being pushed out (`nextDue` from `list_due_care`/
|
|
363
|
-
`get_care_calendar` for that plant/careType), not today's date.
|
|
378
|
+
`get_care_calendar` for that plant/careType), not today's date. Pass exactly one of `durationDays`
|
|
379
|
+
(a preset counted from `originalDueAt`, or from today once that date has passed — so "3 days" on
|
|
380
|
+
an overdue plant means three days from now) or `snoozedUntil` (an explicit day: tomorrow at the
|
|
381
|
+
earliest, at most a year out).
|
|
364
382
|
|
|
365
383
|
### Plant edits
|
|
366
384
|
|
|
@@ -580,7 +598,7 @@ registers these tools when the response's `scopes` includes `admin`.
|
|
|
580
598
|
| `admin_list_duplicate_candidates` | `GET /api/v1/admin/plant-types/duplicates` | `status` (`OPEN`\|`DISMISSED`\|`MERGED`, defaults to `OPEN`), `limit` (1–200), `offset` |
|
|
581
599
|
| `admin_list_plant_type_proposals` | `GET /api/v1/admin/plant-types/proposals` | `status` (defaults to `PENDING`), `origin` (`user`\|`migration`), `typeId`, `limit` (1–200), `offset` |
|
|
582
600
|
| `admin_preview_plant_type_merge` | `GET /api/v1/admin/plant-types/merge-preview` | `sourceId`, `targetId` **(both required)** — the per-field carry-over plan and merge guard for both directions |
|
|
583
|
-
| `admin_get_dashboard_status` | `GET /api/v1/admin/dashboard` | none — review-queue counts + oldest-item age, catalog health, weekly activity,
|
|
601
|
+
| `admin_get_dashboard_status` | `GET /api/v1/admin/dashboard` | none — review-queue counts + oldest-item age, catalog health, weekly activity, operational facts (counts only, never a secret), plus optional growth/community/trade/content aggregates (no money, no user details) |
|
|
584
602
|
|
|
585
603
|
`admin_list_plant_types` returns compact rows only (no full field values) — call `admin_get_plant_type`
|
|
586
604
|
for one type's complete fact sheet. Its `search` is fuzzy, the same matcher the admin catalog list
|
|
@@ -597,7 +615,7 @@ changed field `stale` when the type's value has moved on since the proposal was
|
|
|
597
615
|
check a human reviewer's accept button re-runs, so an assistant sees the same warning.
|
|
598
616
|
|
|
599
617
|
`admin_get_dashboard_status` is the one admin tool that isn't about the catalog — it returns the same
|
|
600
|
-
aggregate the app's own `/admin` dashboard shows, for "what needs me, and is anything wrong" in one
|
|
618
|
+
aggregate the app's own `/admin` dashboard shows (including the growth, community, trade and content blocks, each optional and individually `{ available: false }` if its read failed; trade is aggregate counts only, never a price or a party), for "what needs me, and is anything wrong" in one
|
|
601
619
|
call instead of several. Some values are legitimately absent rather than zero: a review-queue item's
|
|
602
620
|
`age` comes back `{ kind: "unknown" }` when its type has no creation timestamp to measure from
|
|
603
621
|
(pending user registrations), and `operations.appliedMigrationsCount` can be `null` when that guarded
|
|
@@ -768,6 +786,6 @@ spec for the tool surface is `docs/mcp-server.md` at the repo root.
|
|
|
768
786
|
variable, so it never lands in the tool arguments a model can see or echo. Treat it like a
|
|
769
787
|
password.
|
|
770
788
|
- Prefer a **read-only key** unless you specifically want an assistant logging care.
|
|
771
|
-
- Revoke a key any time from **Account → API keys**; requests with it start returning 401
|
|
789
|
+
- Revoke a key any time from **Account → Settings → API keys**; requests with it start returning 401
|
|
772
790
|
immediately.
|
|
773
791
|
- All data is scoped to the key's owner. Another user's data returns 404.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared HTTP proxy every tool in `index.ts` is built on: `apiGet` for reads,
|
|
3
|
+
* `apiSend` for writes. Pulled into its own module so it can be unit-tested in
|
|
4
|
+
* isolation — `index.ts` has top-level side effects (env var validation that calls
|
|
5
|
+
* `process.exit`, `McpServer` construction, ~76 tool registrations, stdio connect)
|
|
6
|
+
* that make it unsafe to import directly in a test.
|
|
7
|
+
*
|
|
8
|
+
* Both functions take the API base URL and key as a config object rather than
|
|
9
|
+
* reading them from `process.env` themselves, so a test can point them at a mocked
|
|
10
|
+
* `fetch` with fixture values instead of needing real environment variables.
|
|
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
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { createApiClient } from "./apiClient.js";
|
|
6
7
|
/**
|
|
7
8
|
* MCP server for the Sprig plant app: reads the collection, and — with a
|
|
8
9
|
* write-scoped key — logs care, edits a plant, and deletes a watering or
|
|
@@ -31,95 +32,7 @@ if (!API_KEY) {
|
|
|
31
32
|
console.error("[plants-mcp] Missing PLANT_API_KEY environment variable.");
|
|
32
33
|
process.exit(1);
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
-
const url = new URL(`${API_URL}${path}`);
|
|
36
|
-
for (const [key, value] of Object.entries(query)) {
|
|
37
|
-
if (value !== undefined && value !== "")
|
|
38
|
-
url.searchParams.set(key, String(value));
|
|
39
|
-
}
|
|
40
|
-
let res;
|
|
41
|
-
try {
|
|
42
|
-
res = await fetch(url, {
|
|
43
|
-
headers: {
|
|
44
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
45
|
-
Accept: "application/json",
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
catch (err) {
|
|
50
|
-
return {
|
|
51
|
-
content: [{ type: "text", text: `Network error calling ${url.pathname}: ${String(err)}` }],
|
|
52
|
-
isError: true,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
const body = await res.text();
|
|
56
|
-
if (!res.ok) {
|
|
57
|
-
return {
|
|
58
|
-
content: [
|
|
59
|
-
{
|
|
60
|
-
type: "text",
|
|
61
|
-
text: `Request to ${url.pathname} failed (HTTP ${res.status}): ${body || res.statusText}`,
|
|
62
|
-
},
|
|
63
|
-
],
|
|
64
|
-
isError: true,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
// Pretty-print JSON when possible, otherwise return raw text.
|
|
68
|
-
let text = body;
|
|
69
|
-
try {
|
|
70
|
-
text = JSON.stringify(JSON.parse(body), null, 2);
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
// leave as-is
|
|
74
|
-
}
|
|
75
|
-
return { content: [{ type: "text", text }] };
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Sends a JSON body to a mutating endpoint. Requires an API key with the
|
|
79
|
-
* `write` scope — a read-only key gets a 403 surfaced back to the model verbatim
|
|
80
|
-
* so it can tell the user their key can't write. `DELETE` sends no body.
|
|
81
|
-
*/
|
|
82
|
-
async function apiSend(method, path, body) {
|
|
83
|
-
const url = new URL(`${API_URL}${path}`);
|
|
84
|
-
let res;
|
|
85
|
-
try {
|
|
86
|
-
res = await fetch(url, {
|
|
87
|
-
method,
|
|
88
|
-
headers: {
|
|
89
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
90
|
-
Accept: "application/json",
|
|
91
|
-
...(body ? { "Content-Type": "application/json" } : {}),
|
|
92
|
-
},
|
|
93
|
-
...(body ? { body: JSON.stringify(body) } : {}),
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
catch (err) {
|
|
97
|
-
return {
|
|
98
|
-
content: [{ type: "text", text: `Network error calling ${url.pathname}: ${String(err)}` }],
|
|
99
|
-
isError: true,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
const text = await res.text();
|
|
103
|
-
if (!res.ok) {
|
|
104
|
-
return {
|
|
105
|
-
content: [
|
|
106
|
-
{
|
|
107
|
-
type: "text",
|
|
108
|
-
text: `Request to ${url.pathname} failed (HTTP ${res.status}): ${text || res.statusText}`,
|
|
109
|
-
},
|
|
110
|
-
],
|
|
111
|
-
isError: true,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
let pretty = text;
|
|
115
|
-
try {
|
|
116
|
-
pretty = JSON.stringify(JSON.parse(text), null, 2);
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
// leave as-is
|
|
120
|
-
}
|
|
121
|
-
return { content: [{ type: "text", text: pretty }] };
|
|
122
|
-
}
|
|
35
|
+
const { apiGet, apiSend } = createApiClient({ apiUrl: API_URL, apiKey: API_KEY });
|
|
123
36
|
const server = new McpServer({ name: "plants-mcp", version: VERSION });
|
|
124
37
|
// --- Identity ---------------------------------------------------------------
|
|
125
38
|
server.tool("whoami", "Return the authenticated user for the configured API key.", async () => apiGet("/api/v1/me"));
|
|
@@ -208,6 +121,8 @@ server.tool("sitter_briefing", "Printable care sheet for the caller's current or
|
|
|
208
121
|
locationId: z.number().int().optional().describe("Only include plants in this location (id from list_locations). Omit for every plant on the vacation."),
|
|
209
122
|
}, async (args) => apiGet("/api/v1/vacation/sitter-briefing", args));
|
|
210
123
|
server.tool("get_vacation_status", "Whether the caller has a current or upcoming vacation, in either mode ('sitter' or 'shift') — unlike sitter_briefing, which only ever reports a 'sitter'-mode one. Returns { hasVacation: false } when none is running or scheduled, otherwise the vacation's id, mode, date range, whether it has started yet (isActive/isUpcoming), and how many plants it covers. Read-only status only — for a sitter-mode vacation's full per-plant detail and schedule, use sitter_briefing.", async () => apiGet("/api/v1/vacation/status"));
|
|
124
|
+
// --- Nudges -------------------------------------------------------------
|
|
125
|
+
server.tool("list_nudges", "Gaps in the collection worth mentioning: stale or missing photos, plants without a type or location, long-unresolved health entries, and app features the caller has never tried. This is the low-pressure 'is there anything I should suggest?' tool — distinct from due/overdue care, which is deadline-driven. Returns the caller's whole ranked queue (not just the top one the app itself would show), each entry as { kind, variant, count, href }: count is an aggregate number for that kind (0 for a feature-discovery entry, which pitches a feature rather than a quantity), and href is the in-app page to send the user to. An empty list means nothing is currently worth nudging about.", async () => apiGet("/api/v1/nudges"));
|
|
211
126
|
// --- Care logging (writes) --------------------------------------------------
|
|
212
127
|
/**
|
|
213
128
|
* Mutating tools. These require an API key with the `write` scope; a read-only
|
|
@@ -261,10 +176,11 @@ server.tool("record_hydro_event", "WRITE: log a hydro-culture event for a hydro-
|
|
|
261
176
|
recordedAt: optionalPlantDate(""),
|
|
262
177
|
notes: z.string().max(2000).optional().describe("Free-text notes."),
|
|
263
178
|
}, async ({ plantId, ...body }) => apiSend("POST", `/api/v1/plants/${plantId}/hydro`, body));
|
|
264
|
-
server.tool("snooze_care", "WRITE: postpone a plant's next watering or fertilizing due date
|
|
179
|
+
server.tool("snooze_care", "WRITE: postpone a plant's next watering or fertilizing due date — the same snooze picker the care hub offers. originalDueAt is the due date being pushed out (the nextDue value from list_due_care/get_care_calendar for that plant/careType), not today's date. Pass exactly one of durationDays (a preset: 1, 3, 7, or 14 days, counted from originalDueAt or from today when that date has already passed) or snoozedUntil (an explicit YYYY-MM-DD day, tomorrow at the earliest and at most a year out). Requires a write-scoped API key.", {
|
|
265
180
|
plantId: z.number().int().positive().describe("Plant id."),
|
|
266
181
|
careType: z.enum(["water", "fert"]).describe("Which schedule to postpone."),
|
|
267
|
-
durationDays: z.union([z.literal(1), z.literal(3), z.literal(7), z.literal(14)]).describe("Snooze duration in days — one of the app's presets."),
|
|
182
|
+
durationDays: z.union([z.literal(1), z.literal(3), z.literal(7), z.literal(14)]).optional().describe("Snooze duration in days — one of the app's presets, counted from originalDueAt, or from today if that date has passed. Omit when passing snoozedUntil."),
|
|
183
|
+
snoozedUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("The day the task should come back, as YYYY-MM-DD — after today, within the next 365 days. Omit when passing durationDays."),
|
|
268
184
|
originalDueAt: z.string().describe("The due date being pushed out, e.g. from list_due_care's nextDue for this plant/careType."),
|
|
269
185
|
note: z.string().max(160).optional().describe("Free-text reason, e.g. 'away for the week'."),
|
|
270
186
|
}, async (args) => apiSend("POST", "/api/v1/care/snooze", { ...args }));
|
|
@@ -613,7 +529,7 @@ function registerAdminTools() {
|
|
|
613
529
|
note: z.string().min(1).describe("Required, but not persisted — see the tool description."),
|
|
614
530
|
}, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/duplicates/${id}/dismiss`, { note }));
|
|
615
531
|
server.tool("admin_rescan_duplicate_candidates", "ADMIN WRITE: re-run duplicate detection against the whole live catalog and reconcile the duplicate-candidate queue — the same 'Rescan' button the admin Duplicates tab has, for when catalog edits (new types, merges, name or identity changes) since the last migration run mean the queue no longer reflects what a fresh scan would produce. A previously dismissed or already-merged pair never comes back. No note needed: this only rebuilds the queue, there is no reviewable decision to leave a reason for. Returns how many candidates were added, updated, and removed. Requires an admin-scoped API key.", async () => apiSend("POST", "/api/v1/admin/plant-types/duplicates/rescan", {}));
|
|
616
|
-
server.tool("admin_get_dashboard_status", "ADMIN: the instance's operational status in one call — the same aggregate the app's own /admin dashboard shows. Four groups: reviewQueue (pending proposals, pending submitted images, open duplicate candidates, unverified types, and pending user registrations — each with its count and the age in days of its oldest item), catalogHealth (verified/unverified split, types with no care values at all, orphan cultivars, types with no plants, low-completeness types, and plants still unidentified), activity (new plants, care events logged, and contributions submitted/decided, per trailing week over the last several weeks), and operations (push-enabled users, pending email verifications, active API keys, and the applied migration count — counts only, never a secret, token, key or hash). No arguments; always reads the whole instance. Some values are legitimately absent rather than zero: a queue's age comes back as 'unknown' when its item type has no creation timestamp to measure from (pending user registrations have no such column), and the applied-migration count can be null when that guarded read failed — neither is a fabricated number. Read-only; nothing here writes. Requires an admin-scoped API key.", async () => apiGet("/api/v1/admin/dashboard"));
|
|
532
|
+
server.tool("admin_get_dashboard_status", "ADMIN: the instance's operational status in one call — the same aggregate the app's own /admin dashboard shows. Four groups: reviewQueue (pending proposals, pending submitted images, open duplicate candidates, unverified types, and pending user registrations — each with its count and the age in days of its oldest item), catalogHealth (verified/unverified split, types with no care values at all, orphan cultivars, types with no plants, low-completeness types, and plants still unidentified), activity (new plants, care events logged, and contributions submitted/decided, per trailing week over the last several weeks), and operations (push-enabled users, pending email verifications, active API keys, and the applied migration count — counts only, never a secret, token, key or hash). Four more optional groups follow, each independently `{ available: false }` when its reads failed: growth (accounts by status, care-active users over 7/30 days, active users with no plant, users with push or an API key — sign-ups per week are not tracked), community (posts/comments/reactions per week, open reports with age, muted users, follows, active share links), trade (aggregate deal and offer counts by status, sold vs gifted vs traded, wishlist size — never prices, money or parties), and content (photos and 30-day growth, living vs archived plants, plant analyses, AI plausibility checks, notifications sent vs read over 30 days). No arguments; always reads the whole instance. Some values are legitimately absent rather than zero: a queue's age comes back as 'unknown' when its item type has no creation timestamp to measure from (pending user registrations have no such column), and the applied-migration count can be null when that guarded read failed — neither is a fabricated number. Read-only; nothing here writes. Requires an admin-scoped API key.", async () => apiGet("/api/v1/admin/dashboard"));
|
|
617
533
|
}
|
|
618
534
|
/**
|
|
619
535
|
* Calls `/api/v1/me` once at startup to decide whether this key carries the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sebamomann/plants-mcp",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "MCP server for the Sprig plant app:
|
|
3
|
+
"version": "2.7.0",
|
|
4
|
+
"description": "MCP server for the Sprig plant app: 77 tools to read a plant collection, browse the shared global plant type catalog, log care (watering, fertilizing, repotting, refills, hydro events), edit a plant's identity/care/lifecycle status, add, propagate or merge plants, snooze a due date, check for nudges worth mentioning, manage a wishlist and notifications, watch a plant type for changes, check vacation status, read past problem diagnoses, create a location/soil/fertilizer/pot, and contribute a new species to the catalog. Read-only by default; writes need a write-scoped API key. Two tools can delete a watering/fertilization event; nothing else deletes collection history. 18 more tools read, edit, merge and review the shared global plant type catalog and the instance's operational status (queues, catalog health, activity, growth, community, trade and content aggregates) for an admin-scoped key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "sebamomann <github@sebamomann.de>",
|
|
@@ -44,14 +44,17 @@
|
|
|
44
44
|
"dev": "tsx src/index.ts",
|
|
45
45
|
"typecheck": "tsc --noEmit",
|
|
46
46
|
"check:docs": "node scripts/check-tool-docs.mjs",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest",
|
|
47
49
|
"prepublishOnly": "npm run build"
|
|
48
50
|
},
|
|
49
51
|
"dependencies": {
|
|
50
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
51
|
-
"zod": "^4.
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
53
|
+
"zod": "^4.6.5"
|
|
52
54
|
},
|
|
53
55
|
"devDependencies": {
|
|
54
|
-
"@types/node": "^26.
|
|
55
|
-
"typescript": "^6.0.3"
|
|
56
|
+
"@types/node": "^26.6.2",
|
|
57
|
+
"typescript": "^6.0.3",
|
|
58
|
+
"vitest": "^5.0.1"
|
|
56
59
|
}
|
|
57
60
|
}
|