@sebamomann/plants-mcp 2.5.0 → 2.5.1

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,16 @@
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.5.1 — 2026-09-20
7
+
8
+ **Richer admin dashboard status.** Tool count unchanged (76 overall). A **patch** bump — the
9
+ `admin_get_dashboard_status` response gains four optional groups, nothing renamed or removed:
10
+
11
+ - `growth`, `community`, `trade`, `content` — accounts by status and care-active users, community
12
+ activity per week and open reports with age, aggregate deal/offer counts (never money or parties),
13
+ and photo/plant/analysis/notification totals. Each is independently `{ available: false }` when
14
+ its reads failed. See `docs/mcp-server.md`'s "Admin dashboard status".
15
+
6
16
  ## 2.5.0 — 2026-09-16
7
17
 
8
18
  **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
 
@@ -580,7 +580,7 @@ registers these tools when the response's `scopes` includes `admin`.
580
580
  | `admin_list_duplicate_candidates` | `GET /api/v1/admin/plant-types/duplicates` | `status` (`OPEN`\|`DISMISSED`\|`MERGED`, defaults to `OPEN`), `limit` (1–200), `offset` |
581
581
  | `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
582
  | `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) |
583
+ | `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
584
 
585
585
  `admin_list_plant_types` returns compact rows only (no full field values) — call `admin_get_plant_type`
586
586
  for one type's complete fact sheet. Its `search` is fuzzy, the same matcher the admin catalog list
@@ -597,7 +597,7 @@ changed field `stale` when the type's value has moved on since the proposal was
597
597
  check a human reviewer's accept button re-runs, so an assistant sees the same warning.
598
598
 
599
599
  `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
600
+ 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
601
  call instead of several. Some values are legitimately absent rather than zero: a review-queue item's
602
602
  `age` comes back `{ kind: "unknown" }` when its type has no creation timestamp to measure from
603
603
  (pending user registrations), and `operations.appliedMigrationsCount` can be `null` when that guarded
@@ -768,6 +768,6 @@ spec for the tool surface is `docs/mcp-server.md` at the repo root.
768
768
  variable, so it never lands in the tool arguments a model can see or echo. Treat it like a
769
769
  password.
770
770
  - 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
771
+ - Revoke a key any time from **Account → Settings → API keys**; requests with it start returning 401
772
772
  immediately.
773
773
  - 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"));
@@ -613,7 +526,7 @@ function registerAdminTools() {
613
526
  note: z.string().min(1).describe("Required, but not persisted — see the tool description."),
614
527
  }, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/duplicates/${id}/dismiss`, { note }));
615
528
  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"));
529
+ 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
530
  }
618
531
  /**
619
532
  * 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.5.1",
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 (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,6 +44,8 @@
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": {
@@ -52,6 +54,7 @@
52
54
  },
53
55
  "devDependencies": {
54
56
  "@types/node": "^26.1.1",
55
- "typescript": "^6.0.3"
57
+ "typescript": "^6.0.3",
58
+ "vitest": "^4.1.10"
56
59
  }
57
60
  }