@sebamomann/plants-mcp 1.7.0 → 1.8.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 +19 -0
- package/README.md +36 -4
- package/dist/index.js +20 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
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
|
+
## 1.8.0 — 2026-08-08
|
|
7
|
+
|
|
8
|
+
**Plants can now be created, propagated, and merged from here.** Four new tools — 33 total (19
|
|
9
|
+
read, 14 write):
|
|
10
|
+
|
|
11
|
+
- `create_plant` — one plant of an existing `plantTypeId` (from `list_plant_types`; creating a
|
|
12
|
+
plant *type* is still UI-only). `reference: true` creates the `EXTERNAL` lineage anchor added in
|
|
13
|
+
1.7.0, so a cutting from someone else's plant can finally name its actual mother plant.
|
|
14
|
+
- `propagate_plant` — 1–10 cuttings of a plant, each parent-linked and starting `PROPAGATING`,
|
|
15
|
+
copying the parent's type, location, fertilizer and care schedule.
|
|
16
|
+
- `merge_plants` — merge living plants into an existing survivor (`survivorPlantId`) or a new one
|
|
17
|
+
seeded from a plant's care config (`createNewFrom`). Non-destructive: sources become `MERGED` and
|
|
18
|
+
keep their own care history, so the survivor starts with an empty one.
|
|
19
|
+
- `unmerge_plant` — reverses a merge for one source plant, back to `LIVING`.
|
|
20
|
+
|
|
21
|
+
`update_plant` also gained **`parentPlantId`**, which makes propagation lineage writable after the
|
|
22
|
+
fact (and clearable with `null`). It runs the app's own cycle check, so a plant cannot be made a
|
|
23
|
+
descendant of itself. Lineage was already readable via `get_plant`; this closes the write half.
|
|
24
|
+
|
|
6
25
|
## 1.7.0 — 2026-08-08
|
|
7
26
|
|
|
8
27
|
**New plant status: `EXTERNAL` (reference plant).** A plant somebody else owns, recorded only as
|
package/README.md
CHANGED
|
@@ -60,12 +60,13 @@ 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
|
+
**33 tools** over your collection: 19 that read it, 14 that write to it.
|
|
64
64
|
|
|
65
65
|
Reads cover plants, watering and fertilization history, the full care timeline, photo metadata,
|
|
66
66
|
health entries, the derived care schedule, collection-wide activity, and the lookup catalogs.
|
|
67
67
|
Writes cover recording waterings and fertilizings, health notes, dismissing care recommendations,
|
|
68
|
-
editing a plant's care schedule or settings,
|
|
68
|
+
editing a plant's care schedule or settings, adding a plant, propagating one, merging plants
|
|
69
|
+
together and reversing that merge, and deleting a watering or fertilization event.
|
|
69
70
|
|
|
70
71
|
**Deleting a plant, a photo, or a catalog entry is not possible from here** — that stays UI-only.
|
|
71
72
|
`delete_watering_event` and `delete_fertilization_event` are the two exceptions (see **What this
|
|
@@ -98,7 +99,7 @@ healthy server, not a hang — press Ctrl-C.
|
|
|
98
99
|
The scope lives on the API key, is enforced by the app, and cannot be widened from this side.
|
|
99
100
|
|
|
100
101
|
- **`read`** — on every key. Gates all 19 read tools.
|
|
101
|
-
- **`write`** — opt-in when you create the key. Gates the
|
|
102
|
+
- **`write`** — opt-in when you create the key. Gates the 14 write tools.
|
|
102
103
|
|
|
103
104
|
| Situation | HTTP | What the assistant sees |
|
|
104
105
|
|---|---|---|
|
|
@@ -247,7 +248,7 @@ thing per plant.
|
|
|
247
248
|
| Tool | Endpoint | Arguments |
|
|
248
249
|
|---|---|---|
|
|
249
250
|
| `update_plant_care` | `PATCH /api/v1/plants/:id/care` | `plantId` **(required)**, plus any of `wateringMode` (`scheduled`\|`reservoir`\|`hydro`), `fertilizingMode` (`scheduled`\|`with_watering`), `sunRequirement` (1–4), `waterRequirement` (1–3), `wateringFrequencySummer`/`wateringFrequencyWinter` (labels), `wateringFrequencySummerDays`/`wateringFrequencyWinterDays` (1–365), `wateringNotes`, `fertilizingCycleSummerWeeks`/`fertilizingCycleWinterWeeks` (0–52), `fertilizerPercent` (0–1000), `fertilizingNotes` |
|
|
250
|
-
| `update_plant` | `PATCH /api/v1/plants/:id` | `plantId` **(required)**, plus any of `locationId`, `soilId`, `fertilizerId`, `quantity` (0–9999), `notes`, `tags` (replaces the full list), `sitterInstructions` |
|
|
251
|
+
| `update_plant` | `PATCH /api/v1/plants/:id` | `plantId` **(required)**, plus any of `locationId`, `soilId`, `fertilizerId`, `quantity` (0–9999), `notes`, `tags` (replaces the full list), `sitterInstructions`, `parentPlantId` |
|
|
251
252
|
|
|
252
253
|
Both take a `plantId` and edit **only the fields you pass** — an omitted field keeps its current
|
|
253
254
|
value, `null` clears it (unassigns a catalog id, or blanks a text field).
|
|
@@ -265,6 +266,37 @@ recommendation's own text says "edit the plant," and the mode causing the mismat
|
|
|
265
266
|
`update_plant`'s `locationId`/`soilId`/`fertilizerId` must be one of **your own** catalog ids (from
|
|
266
267
|
`list_locations`/`list_soils`/`list_fertilizers`) — an unowned or unknown id is rejected.
|
|
267
268
|
|
|
269
|
+
`update_plant`'s `parentPlantId` records lineage after the fact — the plant this one was propagated
|
|
270
|
+
from. It must be one of your own plants (an `EXTERNAL` reference plant counts) and cannot be one of
|
|
271
|
+
this plant's own descendants, which would close a cycle in the lineage graph. When the cuttings
|
|
272
|
+
don't exist yet, `propagate_plant` creates them *and* sets the link in one call.
|
|
273
|
+
|
|
274
|
+
### Plant lifecycle
|
|
275
|
+
|
|
276
|
+
| Tool | Endpoint | Arguments |
|
|
277
|
+
|---|---|---|
|
|
278
|
+
| `create_plant` | `POST /api/v1/plants` | `plantTypeId` **(required)**, `reference` (default `false`), `parentPlantId` |
|
|
279
|
+
| `propagate_plant` | `POST /api/v1/plants/:id/propagations` | `plantId` **(required)**, `count` (1–10, default 1) |
|
|
280
|
+
| `merge_plants` | `POST /api/v1/plants/merge` | `sourcePlantIds` **(required,** 1–200**)**, plus **exactly one of** `survivorPlantId` or `createNewFrom` |
|
|
281
|
+
| `unmerge_plant` | `POST /api/v1/plants/:id/unmerge` | `plantId` **(required)** |
|
|
282
|
+
|
|
283
|
+
`create_plant` needs an **existing** `plantTypeId` from `list_plant_types` — creating a plant *type*
|
|
284
|
+
is still a UI action, so if the species isn't in your catalog yet, that step happens in the app.
|
|
285
|
+
|
|
286
|
+
`reference: true` creates an `EXTERNAL` plant: somebody else's, recorded only so lineage has a real
|
|
287
|
+
node to point at. It gets no acquisition date, no care schedule, and no place in collection stats —
|
|
288
|
+
so a cutting you were given can name its actual mother plant instead of just starting from nowhere.
|
|
289
|
+
|
|
290
|
+
`merge_plants` is for rows that turned out to be the same plant, or cuttings potted together. It is
|
|
291
|
+
**reversible and non-destructive**: the sources become `MERGED`, keep their own care history, and
|
|
292
|
+
record which plant they went into, which is what `unmerge_plant` reads to put one back. Two
|
|
293
|
+
consequences worth knowing before you call it: the survivor starts with an **empty care history**
|
|
294
|
+
(events stay on the sources rather than moving), and `unmerge_plant` restores **one** plant per
|
|
295
|
+
call. All sources must be `LIVING`, and `survivorPlantId` must not also appear in `sourcePlantIds`.
|
|
296
|
+
|
|
297
|
+
Unmerging leaves the survivor in place. If the merge created it via `createNewFrom` and you no
|
|
298
|
+
longer want it, deleting that plant is a UI action.
|
|
299
|
+
|
|
268
300
|
### Health entries
|
|
269
301
|
|
|
270
302
|
| Tool | Endpoint | Arguments |
|
package/dist/index.js
CHANGED
|
@@ -245,7 +245,7 @@ server.tool("update_plant_care", "WRITE: edit a plant's care schedule — wateri
|
|
|
245
245
|
fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose."),
|
|
246
246
|
fertilizingNotes: z.string().max(500).nullable().optional().describe("Free-text fertilizing notes."),
|
|
247
247
|
}, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}/care`, body));
|
|
248
|
-
server.tool("update_plant", "WRITE: edit a plant's location, soil, fertilizer, quantity, notes, tags, or
|
|
248
|
+
server.tool("update_plant", "WRITE: edit a plant's location, soil, fertilizer, quantity, notes, tags, sitter instructions, or parent plant. Omitted fields keep their current value; null unassigns a catalog entry, clears notes/instructions, empties the tag list, or detaches the plant from its parent. Requires a write-scoped API key.", {
|
|
249
249
|
plantId: z.number().int().positive().describe("Plant id."),
|
|
250
250
|
locationId: z.number().int().positive().nullable().optional().describe("New location id, from list_locations. null unassigns it."),
|
|
251
251
|
soilId: z.number().int().positive().nullable().optional().describe("New soil id, from list_soils. null unassigns it."),
|
|
@@ -254,6 +254,7 @@ server.tool("update_plant", "WRITE: edit a plant's location, soil, fertilizer, q
|
|
|
254
254
|
notes: z.string().max(5000).nullable().optional().describe("Free-text notes."),
|
|
255
255
|
tags: z.array(z.string().max(40)).nullable().optional().describe("Replaces the plant's full tag list (freeform labels, not a catalog). null or [] clears all tags."),
|
|
256
256
|
sitterInstructions: z.string().max(500).nullable().optional().describe("Care instructions shown on a sitter link."),
|
|
257
|
+
parentPlantId: z.number().int().positive().nullable().optional().describe("The plant this one was propagated from — use it to record lineage after the fact. Must be one of the user's own plants (including an EXTERNAL reference plant) and must not be a descendant of this plant, which would form a cycle. null detaches it from its current parent. To create the cuttings in the first place, prefer propagate_plant."),
|
|
257
258
|
}, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}`, body));
|
|
258
259
|
server.tool("add_health_entry", "WRITE: add a manual health note to one plant — an observation (e.g. 'new leaf unfurling') or an issue (e.g. 'spider mites on undersides'). Requires a write-scoped API key.", {
|
|
259
260
|
plantId: z.number().int().positive().describe("Plant id."),
|
|
@@ -301,6 +302,24 @@ server.tool("delete_fertilization_event", "WRITE: delete a fertilization event f
|
|
|
301
302
|
plantId: z.number().int().positive().describe("Plant id."),
|
|
302
303
|
eventId: z.number().int().positive().describe("Event id, from list_fertilization_events or list_care_events."),
|
|
303
304
|
}, async ({ plantId, eventId }) => apiSend("DELETE", `/api/v1/plants/${plantId}/fertilization-events/${eventId}`));
|
|
305
|
+
// --- Plant lifecycle (writes) ------------------------------------------------
|
|
306
|
+
server.tool("create_plant", "WRITE: add a new plant to the collection. plantTypeId is required and must be an existing type — call list_plant_types first and pick the matching one; creating a new plant type is not possible here and stays a UI action. Set reference: true to record somebody else's plant (status EXTERNAL): it is not one of the user's own plants, gets no acquisition date and no care schedule, and is excluded from collection stats — its purpose is to be a real node that propagation lineage can point at. Requires a write-scoped API key.", {
|
|
307
|
+
plantTypeId: z.number().int().positive().describe("Plant type id, from list_plant_types."),
|
|
308
|
+
reference: z.boolean().optional().describe("true creates an EXTERNAL reference plant (somebody else's, a lineage anchor only). Defaults to false: a LIVING plant of the user's own."),
|
|
309
|
+
parentPlantId: z.number().int().positive().optional().describe("Optional: the plant this one was propagated from."),
|
|
310
|
+
}, async (body) => apiSend("POST", "/api/v1/plants", { ...body }));
|
|
311
|
+
server.tool("propagate_plant", "WRITE: create cuttings/offsets of a plant. Each new plant is linked back to the parent as its lineage, copies the parent's type, location, fertilizer and care schedule, and starts as PROPAGATING (not yet established). Prefer this over create_plant when the new plants came from an existing one — it records the parent link that create_plant would leave you to set separately. Requires a write-scoped API key.", {
|
|
312
|
+
plantId: z.number().int().positive().describe("The parent plant the cuttings came from."),
|
|
313
|
+
count: z.number().int().min(1).max(10).optional().describe("How many propagations to create. Defaults to 1, max 10."),
|
|
314
|
+
}, async ({ plantId, count }) => apiSend("POST", `/api/v1/plants/${plantId}/propagations`, { count }));
|
|
315
|
+
server.tool("merge_plants", "WRITE: merge several living plants into one — for rows that turned out to be the same plant, or cuttings potted together. Pass exactly one of survivorPlantId (an existing plant absorbs the others) or createNewFrom (a new plant is created, seeded from that plant's care config). The source plants are NOT deleted: they become MERGED, keep their own care history, and point at the survivor, so unmerge_plant reverses this. Note that care events stay on the source plants rather than moving to the survivor — the survivor starts with an empty history. All sources must currently be LIVING. Requires a write-scoped API key.", {
|
|
316
|
+
sourcePlantIds: z.array(z.number().int().positive()).min(1).max(200).describe("The plants being merged away. All must be LIVING. Must not contain survivorPlantId."),
|
|
317
|
+
survivorPlantId: z.number().int().positive().optional().describe("An existing plant that absorbs the sources. Mutually exclusive with createNewFrom."),
|
|
318
|
+
createNewFrom: z.number().int().positive().optional().describe("Create a new plant as the survivor, copying type, location, soil, fertilizer and care schedule from this plant id (usually one of the sources). Mutually exclusive with survivorPlantId."),
|
|
319
|
+
}, async (body) => apiSend("POST", "/api/v1/plants/merge", { ...body }));
|
|
320
|
+
server.tool("unmerge_plant", "WRITE: reverse a merge for one plant — takes a MERGED plant back to LIVING and clears its link to the survivor. Undoes one source at a time, so call it per plant to fully reverse a multi-plant merge. The survivor is left in place; delete it in the UI if it was created by the merge and is no longer wanted. Requires a write-scoped API key.", {
|
|
321
|
+
plantId: z.number().int().positive().describe("The MERGED plant to restore."),
|
|
322
|
+
}, async ({ plantId }) => apiSend("POST", `/api/v1/plants/${plantId}/unmerge`));
|
|
304
323
|
// --- Catalogs (for resolving filter ids) -----------------------------------
|
|
305
324
|
server.tool("list_locations", "List the user's locations.", async () => apiGet("/api/v1/locations"));
|
|
306
325
|
server.tool("list_plant_types", "List the user's plant types (species taxonomy).", async () => apiGet("/api/v1/plant-types"));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sebamomann/plants-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "MCP server for the Sprig plant app:
|
|
3
|
+
"version": "1.8.0",
|
|
4
|
+
"description": "MCP server for the Sprig plant app: 33 tools to read a plant collection, log care, edit a plant, and add, propagate or merge plants. Read-only by default; writes need a write-scoped API key. Two tools can delete a watering/fertilization event; nothing else is destructive.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "sebamomann <github@sebamomann.de>",
|