@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 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
- **76 tools** over your collection: 27 that read it, 31 that write to it. **18 admin tools** are also
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, the wishlist, watched types, notifications, vacation status, and the lookup catalogs
72
- (including pots). Writes cover recording waterings, fertilizings, repottings, reservoir refills and
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 27 read tools.
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), `originalDueAt` **(all required)**, `note` |
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, and operational facts (counts only, never a secret) |
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
- async function apiGet(path, query = {}) {
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 by a fixed duration — 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. durationDays must be one of the app's presets: 1, 3, 7, or 14. Requires a write-scoped API key.", {
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.5.0",
4
- "description": "MCP server for the Sprig plant app: 76 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, 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 for an admin-scoped key.",
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.29.0",
51
- "zod": "^4.4.3"
52
+ "@modelcontextprotocol/sdk": "^1.30.1",
53
+ "zod": "^4.6.5"
52
54
  },
53
55
  "devDependencies": {
54
- "@types/node": "^26.1.1",
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
  }