@sebamomann/plants-mcp 1.8.0 → 1.9.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,74 @@
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.9.0 — 2026-09-15
7
+
8
+ **Admin keys can now read, directly edit, merge, and review the shared global plant type catalog.**
9
+ Seventeen tools total for an `admin`-scoped key — 50 registered overall (19 read, 14 write, 17
10
+ admin: 6 read-only, 11 that write) — registered only for a key that carries the `admin` scope (a
11
+ `whoami` call at startup decides; a non-admin key's session never sees them):
12
+
13
+ - `admin_list_plant_types` — filtered/paged catalog rows: status, fuzzy search, genus, a species'
14
+ cultivar children, a missing field, or open proposals.
15
+ - `admin_get_plant_type` — one type's full detail: identity/status, parent/children, names,
16
+ own/inherited/effective values, plant and user counts, open proposals, open duplicate
17
+ candidates, image count, and its recent change log. A merged type still resolves —
18
+ `mergedIntoId` names the redirect.
19
+ - `admin_get_plant_type_field_schema` — every guidance field's kind, bounds, allowed values, and
20
+ what a scale value or unit means, derived from the same registry the app validates writes
21
+ against.
22
+ - `admin_list_duplicate_candidates` — the duplicate-detection queue: matched rule, score, and the
23
+ fields where a pair actually disagrees.
24
+ - `admin_list_plant_type_proposals` — the proposal review queue, each changed field carrying the
25
+ type's current value and a `stale` flag using the exact check a human reviewer's accept button
26
+ re-runs.
27
+ - `admin_preview_plant_type_merge` — the same comparison the admin merge confirmation dialog
28
+ loads: both sides' values, the per-field carry-over plan and its defaults for both directions,
29
+ counts that would move, and the guard error for either direction, if any.
30
+
31
+ **Five write tools**, added alongside the reads above, every description prefixed `ADMIN WRITE:`
32
+ and every one requiring a non-empty `note` recorded on the type's change log:
33
+
34
+ - `admin_create_plant_type` — add a new type: genus (required), species, cultivar, an initial name
35
+ list, catalog field values, and status (defaults `VERIFIED`). A cultivar attaches to its existing
36
+ species-level parent automatically; a missing parent is reported (`missingParentBotanicalKey`)
37
+ rather than auto-created. When the new type is itself species-level, existing orphans waiting for
38
+ it re-parent automatically (`adoptedOrphanIds`).
39
+ - `admin_update_plant_type_fields` — patch a type's own catalog field values; a value set to `null`
40
+ clears it back to inherited.
41
+ - `admin_set_plant_type_names` — replace a type's full name list.
42
+ - `admin_update_plant_type_botanical_identity` — edit genus/species/cultivar, which re-derives
43
+ `parentId` and re-parents orphans the same way creation does. A `botanical_key_conflict` (HTTP
44
+ 409, `conflictingTypeId`) means the target identity already exists — the intended response is
45
+ `admin_preview_plant_type_merge` and a merge in the app, not forcing the edit through.
46
+ - `admin_verify_plant_type` — promote an `UNVERIFIED` type to `VERIFIED`.
47
+
48
+ **Six more write tools**, added alongside the five above, closing the gap the previous
49
+ release still left UI-only — merging duplicates and reviewing proposals/duplicate candidates:
50
+
51
+ - `admin_merge_plant_types` — merge `sourceId` into `targetId`. **Irreversible**: `sourceId` becomes
52
+ a `MERGED` redirect and every plant, name, image, cultivar child, pending proposal, legacy
53
+ mapping, and open duplicate candidate that pointed at it moves onto `targetId`.
54
+ `admin_preview_plant_type_merge` — call it first — already existed; this is the write half.
55
+ `fieldChoices` picks a side only for the fields the preview lists as conflicting.
56
+ - `admin_accept_plant_type_proposal` — accept a pending proposal in full or (via `fields`) in part.
57
+ A field whose current value has moved on since the proposal was filed fails the whole call with
58
+ HTTP 409 (`details.staleFields`) rather than an `allowStale` override.
59
+ - `admin_deny_plant_type_proposal` — deny a pending proposal. Never touches the type's own change
60
+ log — a denial is private to its author and the reviewer.
61
+ - `admin_deny_plant_type_proposals` — bulk-deny every `PENDING` proposal on one type, optionally by
62
+ `origin`, for clearing a batch of migration-filed proposals in one call.
63
+ - `admin_dismiss_duplicate_candidate` — dismiss an `OPEN` duplicate pair as not a duplicate. `note`
64
+ is required for a consistent tool contract but **not stored**: the candidate row has no column
65
+ for one.
66
+ - `admin_rescan_duplicate_candidates` — re-run duplicate detection against the live catalog and
67
+ reconcile the queue; no note needed, since it makes no decision.
68
+
69
+ Still UI-only, not covered by this release: nothing that was already exposed as a read here.
70
+ Deleting/unlinking a type, image review, and the AI plausibility check remain deliberately
71
+ excluded, not just deferred — see the "Admin catalog surface" section of `docs/mcp-server.md` for
72
+ why.
73
+
6
74
  ## 1.8.0 — 2026-08-08
7
75
 
8
76
  **Plants can now be created, propagated, and merged from here.** Four new tools — 33 total (19
package/README.md CHANGED
@@ -60,7 +60,11 @@ 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
- **33 tools** over your collection: 19 that read it, 14 that write to it.
63
+ **50 tools** over your collection: 19 that read it, 14 that write to it. **17 admin tools** are also
64
+ available — but only to a key created by an admin — for finding and understanding duplicates in the
65
+ shared global plant type catalog, restructuring its genus/species/cultivar tree, and (6 read-only, 11
66
+ that write) reading, directly editing, merging, and reviewing proposals and duplicate candidates in
67
+ that catalog. See **Admin tools** below.
64
68
 
65
69
  Reads cover plants, watering and fertilization history, the full care timeline, photo metadata,
66
70
  health entries, the derived care schedule, collection-wide activity, and the lookup catalogs.
@@ -71,7 +75,7 @@ together and reversing that merge, and deleting a watering or fertilization even
71
75
  **Deleting a plant, a photo, or a catalog entry is not possible from here** — that stays UI-only.
72
76
  `delete_watering_event` and `delete_fertilization_event` are the two exceptions (see **What this
73
77
  server deliberately cannot do** below). Reads need only the `read` scope that every key has;
74
- writes need a key created with the `write` scope.
78
+ writes need a key created with the `write` scope; the admin tools need the separate `admin` scope.
75
79
 
76
80
  Every tool is a thin proxy over the app's `/api/v1/*` REST API. Results are scoped to the key's
77
81
  owner, and the server holds no logic of its own — which is why it stays correct as the app grows.
@@ -100,6 +104,12 @@ The scope lives on the API key, is enforced by the app, and cannot be widened fr
100
104
 
101
105
  - **`read`** — on every key. Gates all 19 read tools.
102
106
  - **`write`** — opt-in when you create the key. Gates the 14 write tools.
107
+ - **`admin`** — only offered when the key's creator is themselves an admin, and only while they
108
+ still are one (the app re-checks this on every admin-tool call, not just at key creation). Gates
109
+ the 17 admin catalog tools — 6 read-only, 11 that write — see **Admin tools** below. `admin` alone
110
+ is enough for the write ones too (not also `write` — the three scopes are independent grants). An
111
+ admin's key is created with `read`+`write`+`admin` together, so it also works with every ordinary
112
+ tool.
103
113
 
104
114
  | Situation | HTTP | What the assistant sees |
105
115
  |---|---|---|
@@ -350,6 +360,121 @@ the response, not just the HTTP status.
350
360
 
351
361
  ---
352
362
 
363
+ ## Admin tools (admin-scoped keys only)
364
+
365
+ **17 tools** over the shared, community-maintained global plant type catalog — every plant's
366
+ species/cultivar entry, one catalog for the whole app rather than per-user. They need the `admin`
367
+ scope (see **Permissions & scopes** above), so a non-admin key's session never even sees them: the
368
+ server calls `whoami` once at startup and only registers these tools when the response's `scopes`
369
+ includes `admin`.
370
+
371
+ ### Read-only (6)
372
+
373
+ | Tool | Endpoint | Arguments |
374
+ |---|---|---|
375
+ | `admin_list_plant_types` | `GET /api/v1/admin/plant-types` | all optional: `status` (`UNVERIFIED`\|`VERIFIED`\|`MERGED`, defaults to every non-MERGED type), `search` (names + botanical), `genus`, `parentId` (a species-level type's cultivar children), `missingField` (a field key from `admin_get_plant_type_field_schema` — only types missing it), `hasOpenProposals`, `limit` (1–200), `offset` |
376
+ | `admin_get_plant_type` | `GET /api/v1/admin/plant-types/:id` | `id` **(required)** — full detail: identity/status, parent/children, names, own/inherited/effective values, plant/user counts, open proposals, open duplicate candidates, image count, recent change log |
377
+ | `admin_get_plant_type_field_schema` | `GET /api/v1/admin/plant-types/fields` | none — every guidance field's kind, bounds, allowed values, and what a scale value or unit means |
378
+ | `admin_list_duplicate_candidates` | `GET /api/v1/admin/plant-types/duplicates` | `status` (`OPEN`\|`DISMISSED`\|`MERGED`, defaults to `OPEN`), `limit` (1–200), `offset` |
379
+ | `admin_list_plant_type_proposals` | `GET /api/v1/admin/plant-types/proposals` | `status` (defaults to `PENDING`), `origin` (`user`\|`migration`), `typeId`, `limit` (1–200), `offset` |
380
+ | `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 |
381
+
382
+ `admin_list_plant_types` returns compact rows only (no full field values) — call `admin_get_plant_type`
383
+ for one type's complete fact sheet. Its `search` is fuzzy, the same matcher the admin catalog list
384
+ uses in the app. `missingField` and `hasOpenProposals` are the two filters built for finding
385
+ cleanup work: types with a gap in their data, and types with a change proposal still waiting on a
386
+ human.
387
+
388
+ `admin_get_plant_type_field_schema` is never hand-written — it reads off the same registry the app
389
+ validates writes against, so a value an assistant reads back from `admin_get_plant_type` (or would
390
+ write) can always be checked against real bounds/enum values first.
391
+
392
+ `admin_list_plant_type_proposals` and `admin_get_plant_type`'s `openProposals` both mark each
393
+ changed field `stale` when the type's value has moved on since the proposal was filed — the exact
394
+ check a human reviewer's accept button re-runs, so an assistant sees the same warning.
395
+
396
+ ### Write (11)
397
+
398
+ Every description starts with `ADMIN WRITE:`, and **every one of these requires a non-empty `note`**
399
+ — it is recorded on the type's change log entry, so a catalog edit made by an assistant is always
400
+ traceable back to why — except `admin_dismiss_duplicate_candidate` (required for consistency but not
401
+ actually stored — see below) and `admin_rescan_duplicate_candidates` (no note at all — it makes no
402
+ decision). `admin` alone is enough to call these (not also `write` — the three API-key scopes are
403
+ independent grants).
404
+
405
+ | Tool | Endpoint | Arguments |
406
+ |---|---|---|
407
+ | `admin_create_plant_type` | `POST /api/v1/admin/plant-types` | `genus` **(required)**, `species`, `cultivar`, `names` (initial full list), `values` (initial catalog fields), `status` (`UNVERIFIED`\|`VERIFIED`, default `VERIFIED`), `note` **(required)** |
408
+ | `admin_update_plant_type_fields` | `PATCH /api/v1/admin/plant-types/:id/fields` | `id` **(required)**, `values` **(required,** partial — a key set to `null` clears it back to inherited**)**, `note` **(required)** |
409
+ | `admin_set_plant_type_names` | `PUT /api/v1/admin/plant-types/:id/names` | `id` **(required)**, `names` **(required,** the complete replacement list**)**, `note` **(required)** |
410
+ | `admin_update_plant_type_botanical_identity` | `PATCH /api/v1/admin/plant-types/:id/identity` | `id` **(required)**, `genus`, `species`, `cultivar`, `note` **(required)** |
411
+ | `admin_verify_plant_type` | `POST /api/v1/admin/plant-types/:id/verify` | `id` **(required)**, `note` **(required)** |
412
+ | `admin_merge_plant_types` | `POST /api/v1/admin/plant-types/merge` | `sourceId` **(required)**, `targetId` **(required)**, `fieldChoices` (per-field `"source"`\|`"target"`), `note` **(required)** |
413
+ | `admin_accept_plant_type_proposal` | `POST /api/v1/admin/plant-types/proposals/:id/accept` | `id` **(required)**, `fields` (subset to accept — omit for all), `note` **(required)** |
414
+ | `admin_deny_plant_type_proposal` | `POST /api/v1/admin/plant-types/proposals/:id/deny` | `id` **(required)**, `note` **(required)** |
415
+ | `admin_deny_plant_type_proposals` | `POST /api/v1/admin/plant-types/proposals/deny` | `typeId` **(required)**, `origin` (`user`\|`migration`), `note` **(required)** |
416
+ | `admin_dismiss_duplicate_candidate` | `POST /api/v1/admin/plant-types/duplicates/:id/dismiss` | `id` **(required)**, `note` **(required, not stored — see below)** |
417
+ | `admin_rescan_duplicate_candidates` | `POST /api/v1/admin/plant-types/duplicates/rescan` | none |
418
+
419
+ Each of the first six (identity/creation edits and merge) returns `{ type: <the same shape as
420
+ admin_get_plant_type>, ... }`, plus whatever the operation adds (`changed`, `changes`,
421
+ `adoptedOrphanIds`, `missingParentBotanicalKey`, or — for merge — the move counts). The proposal and
422
+ duplicate tools return only the review outcome (see below); they don't reshape a type's own record
423
+ the way an edit does.
424
+
425
+ `admin_create_plant_type`'s `cultivar` attaches the new type under the existing species-level type
426
+ with the matching `genus`/`species` key, named as `parentId` in the response. If that species
427
+ doesn't exist yet, `parentId` comes back `null` and `missingParentBotanicalKey` names the key to
428
+ create first (call `admin_create_plant_type` again, without a cultivar) — a cultivar's parent is
429
+ never auto-created. When the new type is itself species-level, any existing type with no parent
430
+ whose own key implies this one as parent is automatically re-parented onto it — returned as
431
+ `adoptedOrphanIds` — which is how creating a missing species repairs the tree instead of leaving its
432
+ cultivars stranded.
433
+
434
+ `admin_update_plant_type_botanical_identity` re-parents orphans the same way when an edit makes a
435
+ type species-level, and shares the same conflict rule as creation: a `botanical_key_conflict`
436
+ (HTTP 409, `conflictingTypeId` in the error's `details`) means another live type already owns the
437
+ resulting identity — call `admin_preview_plant_type_merge` and merge the two rather than forcing the
438
+ edit through. It also refuses to turn a type with cultivar children into a cultivar itself (v1's
439
+ tree is only two levels deep).
440
+
441
+ `admin_update_plant_type_fields` shares the field registry with the read tools — call
442
+ `admin_get_plant_type_field_schema` first for valid keys and bounds — and returns which fields
443
+ actually changed. Don't set a cultivar's value to the same thing it already inherits; leave it
444
+ `null` so it keeps inheriting instead of freezing a copy that drifts from the parent later.
445
+
446
+ `admin_set_plant_type_names` always replaces the **full** list — call `admin_get_plant_type` first
447
+ and send its complete `names` array back with your change, or names you leave out are dropped.
448
+
449
+ **`admin_merge_plant_types` is irreversible.** `sourceId` becomes a `MERGED` redirect stub, and every
450
+ plant, name, image, cultivar child, pending proposal, legacy mapping, and open duplicate candidate
451
+ that pointed at it moves onto `targetId`. Always call `admin_preview_plant_type_merge` first — same
452
+ comparison a human admin would see. `targetId` keeps its own identity, `parentId`, status, and cover
453
+ image; `fieldChoices` only picks a side for the fields the preview lists as `conflict`/`onlySource` —
454
+ everything else the plan defaults to adopting happens regardless.
455
+
456
+ **`admin_accept_plant_type_proposal`** accepts in full (omit `fields`, status `ACCEPTED`) or in part
457
+ (an explicit subset, status `PARTIAL` unless it happens to cover everything). A field whose current
458
+ value moved on since the proposal was filed fails the whole call with HTTP 409
459
+ (`details.staleFields`) unless it's excluded from `fields` — there is no `allowStale` override here
460
+ like the admin UI's "accept anyway" checkbox; handle that field with `admin_update_plant_type_fields`
461
+ and retry without it, or deny the proposal. **`admin_deny_plant_type_proposal`** never writes a
462
+ change log entry on the type — a denial is private to the proposal's author and reviewer.
463
+ **`admin_deny_plant_type_proposals`** is the bulk variant for clearing every `PENDING` proposal on one
464
+ type at once (optionally by `origin`), for a batch of migration-filed proposals.
465
+
466
+ **`admin_dismiss_duplicate_candidate`'s `note` is required but not stored anywhere** —
467
+ `PlantTypeDuplicateCandidate` has no column for one; the requirement exists only for a consistent
468
+ tool contract, not a change log entry. A dismissed pair never resurfaces, even from
469
+ **`admin_rescan_duplicate_candidates`**, which re-runs detection against the whole live catalog and
470
+ reconciles the queue — the same "Rescan" button in the admin Duplicates tab, for when catalog edits
471
+ since the last migration run mean the queue is stale.
472
+
473
+ Deleting/unlinking a type, reviewing images, and the advisory AI plausibility check are deliberately
474
+ excluded even from the roadmap — see docs/mcp-server.md's Admin catalog surface section for why.
475
+
476
+ ---
477
+
353
478
  ## Responses and errors
354
479
 
355
480
  Tools return the API's JSON **verbatim**, pretty-printed. Nothing is reshaped, renamed, or
package/dist/index.js CHANGED
@@ -6,7 +6,12 @@ import { z } from "zod";
6
6
  /**
7
7
  * MCP server for the Sprig plant app: reads the collection, and — with a
8
8
  * write-scoped key — logs care, edits a plant, and deletes a watering or
9
- * fertilization event. Plants, photos, and catalog entries stay UI-only.
9
+ * fertilization event. Plants and photos stay UI-only. An admin-scoped key
10
+ * additionally sees tools over the shared global plant type catalog
11
+ * (`admin_*`, registered conditionally — see `registerAdminToolsIfPermitted`):
12
+ * reads, creating and editing catalog entries directly, and (since this
13
+ * increment) merging duplicate types and reviewing proposals and duplicate
14
+ * candidates.
10
15
  *
11
16
  * Every tool is a thin proxy over the app's `/api/v1/*` REST endpoints,
12
17
  * authenticated with a per-user API key sent as `Authorization: Bearer <key>`.
@@ -325,8 +330,171 @@ server.tool("list_locations", "List the user's locations.", async () => apiGet("
325
330
  server.tool("list_plant_types", "List the user's plant types (species taxonomy).", async () => apiGet("/api/v1/plant-types"));
326
331
  server.tool("list_soils", "List the user's soils.", async () => apiGet("/api/v1/soils"));
327
332
  server.tool("list_fertilizers", "List the user's fertilizers.", async () => apiGet("/api/v1/fertilizers"));
333
+ // --- Admin: global plant type catalog (admin-scoped keys only) -------------
334
+ /**
335
+ * Tools over the shared, community-maintained plant type catalog — finding
336
+ * and understanding duplicates, restructuring the genus/species/cultivar
337
+ * tree, creating and editing catalog entries directly, and (since this
338
+ * increment) merging two types, reviewing a proposal, and dismissing or
339
+ * rescanning duplicate candidates. Every description starts with `ADMIN:`
340
+ * for a read or `ADMIN WRITE:` for a write; `check-tool-docs.mjs` classifies
341
+ * a tool by that prefix (`WRITE:` for an ordinary write tool, `ADMIN WRITE:`
342
+ * for an admin write, `ADMIN:` for an admin read, anything else an ordinary
343
+ * read) — see the comment at the top of that script.
344
+ *
345
+ * These are registered conditionally — see `registerAdminToolsIfPermitted`
346
+ * below — so a non-admin key's session never sees a tool it can't call.
347
+ * Every `ADMIN WRITE:` tool requires a non-empty `note`, recorded on the
348
+ * catalog's change log so an assistant's edit is always traceable, with two
349
+ * exceptions: `admin_dismiss_duplicate_candidate` requires one for
350
+ * consistency but has nowhere to store it (see its own description), and
351
+ * `admin_rescan_duplicate_candidates` needs none at all (no decision is
352
+ * being made). The same `admin` scope covers both the reads and the writes
353
+ * here (not also `write`; the three API-key scopes are independent grants,
354
+ * see docs/mcp-server.md's Scopes section).
355
+ */
356
+ function registerAdminTools() {
357
+ server.tool("admin_list_plant_types", "ADMIN: list types in the shared global plant type catalog, with filters for finding gaps and problems (missing data, no open proposals reviewed yet). Compact rows only — no full field values; use admin_get_plant_type for one type's full fact sheet. Requires an admin-scoped API key.", {
358
+ status: z.enum(["UNVERIFIED", "VERIFIED", "MERGED"]).optional().describe("Filter by review status. Defaults to every non-MERGED type."),
359
+ search: z.string().optional().describe("Fuzzy search over names (all locales) and the botanical name."),
360
+ genus: z.string().optional().describe("Filter to an exact genus (case-insensitive)."),
361
+ parentId: z.number().int().optional().describe("Filter to the cultivar children of this species-level type id."),
362
+ missingField: z
363
+ .string()
364
+ .optional()
365
+ .describe("A catalog field key (from admin_get_plant_type_field_schema) — only types with no own value for it."),
366
+ hasOpenProposals: z.boolean().optional().describe("true: only types with a pending proposal awaiting review."),
367
+ limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
368
+ offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
369
+ }, async (args) => apiGet("/api/v1/admin/plant-types", { ...args, hasOpenProposals: args.hasOpenProposals?.toString() }));
370
+ server.tool("admin_get_plant_type", "ADMIN: full detail for one global plant type: identity and status, parent/children, names, own/inherited/effective catalog values, plant and user counts, open proposals awaiting review, open duplicate candidates, image count, and its recent change log. A MERGED type still resolves — its own row persists — so `mergedIntoId` names the type it was merged into; follow it rather than treating this as a 404. Requires an admin-scoped API key.", { id: z.number().int().describe("Global plant type id.") }, async ({ id }) => apiGet(`/api/v1/admin/plant-types/${id}`));
371
+ server.tool("admin_get_plant_type_field_schema", "ADMIN: the catalog field registry — every editable guidance field's key, kind, bounds, allowed enum/vocabulary values, whether it's a closed scale and what each scale value means, and its unit/group. Call this before writing any field value with admin_update_plant_type_fields; it is also how to read admin_get_plant_type's own/inherited/effective values correctly. Requires an admin-scoped API key.", async () => apiGet("/api/v1/admin/plant-types/fields"));
372
+ server.tool("admin_list_duplicate_candidates", "ADMIN: the duplicate-candidate queue — pairs of types the catalog's duplicate detection flagged as possibly the same plant, with the matched rule and the catalog fields where they actually disagree. Defaults to OPEN (unreviewed) candidates. Requires an admin-scoped API key.", {
373
+ status: z.enum(["OPEN", "DISMISSED", "MERGED"]).optional().describe("Defaults to OPEN."),
374
+ limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
375
+ offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
376
+ }, async (args) => apiGet("/api/v1/admin/plant-types/duplicates", args));
377
+ server.tool("admin_list_plant_type_proposals", "ADMIN: the proposal review queue — per-field change requests filed against a type, each with the type's current value and a `stale` flag when the type changed since the proposal was filed. Defaults to PENDING. Requires an admin-scoped API key.", {
378
+ status: z.enum(["PENDING", "ACCEPTED", "PARTIAL", "DENIED", "WITHDRAWN"]).optional().describe("Defaults to PENDING."),
379
+ origin: z.enum(["user", "migration"]).optional().describe("Filter by who filed the proposal."),
380
+ typeId: z.number().int().optional().describe("Only proposals for this plant type id."),
381
+ limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
382
+ offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
383
+ }, async (args) => apiGet("/api/v1/admin/plant-types/proposals", args));
384
+ server.tool("admin_preview_plant_type_merge", "ADMIN: preview merging two catalog types before doing it with admin_merge_plant_types. Returns both sides' identity/own values, the per-field carry-over plan and its defaults for both merge directions, counts that would move, and a guard error naming why a direction is disallowed, if any. Always call this before merging — the merge itself is irreversible. Requires an admin-scoped API key.", {
385
+ sourceId: z.number().int().describe("The type that would be merged away."),
386
+ targetId: z.number().int().describe("The type that would survive."),
387
+ }, async ({ sourceId, targetId }) => apiGet("/api/v1/admin/plant-types/merge-preview", { sourceId, targetId }));
388
+ const catalogFieldValues = z
389
+ .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string()), z.null()]))
390
+ .describe("Field values keyed by field name — call admin_get_plant_type_field_schema first for valid keys, kinds, and bounds. null clears a value.");
391
+ const catalogNameEntry = z.object({
392
+ name: z.string().min(1).describe("The name text."),
393
+ locale: z.enum(["de", "en"]).nullable().describe("'de', 'en', or null for any other language."),
394
+ isPrimary: z.boolean().describe("Whether this is the display name shown for its locale."),
395
+ });
396
+ server.tool("admin_create_plant_type", "ADMIN WRITE: create a new type in the shared global plant type catalog. genus is required; species/cultivar are optional and determine the botanical identity key. Setting cultivar attaches the new type as a cultivar under the existing species-level type with the matching genus|species key, if one exists — parentId in the response names it. If no such species exists yet, parentId comes back null and missingParentBotanicalKey names the key to create first (call this tool again without a cultivar, then retry): a cultivar's parent is never auto-created. When the new type is itself species-level (no cultivar), any existing type with no parent whose own key implies this one as parent is automatically re-parented onto it — returned as adoptedOrphanIds — which is how creating a missing species repairs the tree. Fails with a 409 conflict (conflictingTypeId in the error details) when another live type already owns the resulting identity; call admin_preview_plant_type_merge and merge them in the app instead of forcing this. status defaults to VERIFIED (an admin vouching for the new type); pass UNVERIFIED to leave it for review. note is required and recorded on the change log. Requires an admin-scoped API key.", {
397
+ genus: z.string().min(1).describe("Genus, required."),
398
+ species: z.string().nullable().optional().describe("Species epithet. Omit or null for a genus-only/unidentified-species type."),
399
+ cultivar: z.string().nullable().optional().describe("Cultivar name. Setting this makes the new type a cultivar attached under the matching species-level type."),
400
+ names: z.array(catalogNameEntry).optional().describe("Initial full name list. Defaults to none."),
401
+ values: catalogFieldValues.optional().describe("Optional initial catalog field values."),
402
+ status: z.enum(["UNVERIFIED", "VERIFIED"]).optional().describe("Review status. Defaults to VERIFIED."),
403
+ note: z.string().min(1).describe("Required note, recorded on the new type's change log."),
404
+ }, async ({ names, values, status, note, ...identity }) => apiSend("POST", "/api/v1/admin/plant-types", { ...identity, names, values, status, note }));
405
+ server.tool("admin_update_plant_type_fields", "ADMIN WRITE: edit a global plant type's own catalog field values. Call admin_get_plant_type_field_schema first for valid keys/bounds, and admin_get_plant_type to see current own/inherited/effective values. values is a partial patch — only the keys you pass change. Set a key to null to clear the type's own value so it inherits from its parent chain again; do not set a cultivar's value to the same thing it already inherits, since that freezes a copy that will drift from the parent later — leave it null instead. Returns which fields actually changed. note is required and recorded on the change log. Requires an admin-scoped API key.", {
406
+ id: z.number().int().describe("Global plant type id."),
407
+ values: catalogFieldValues,
408
+ note: z.string().min(1).describe("Required note, recorded on the change log."),
409
+ }, async ({ id, values, note }) => apiSend("PATCH", `/api/v1/admin/plant-types/${id}/fields`, { values, note }));
410
+ server.tool("admin_set_plant_type_names", "ADMIN WRITE: replace a global plant type's full name list. This is a full replacement, not a patch — call admin_get_plant_type first and send its complete names array back with your change, or names you leave out are dropped. isPrimary picks the display name shown per locale ('de'/'en'/null for other); at most one primary per locale. note is required and recorded on the change log. Requires an admin-scoped API key.", {
411
+ id: z.number().int().describe("Global plant type id."),
412
+ names: z.array(catalogNameEntry).describe("The complete replacement name list."),
413
+ note: z.string().min(1).describe("Required note, recorded on the change log."),
414
+ }, async ({ id, names, note }) => apiSend("PUT", `/api/v1/admin/plant-types/${id}/names`, { names, note }));
415
+ server.tool("admin_update_plant_type_botanical_identity", "ADMIN WRITE: edit a global plant type's genus/species/cultivar. This also moves the type in the tree: parentId is re-derived from the new genus|species key, attaching to the matching species-level type if one exists. When the edit makes the type species-level, any existing orphaned type whose own key implies this one as parent is automatically re-parented onto it, returned as adoptedOrphanIds — same as admin_create_plant_type. Fails with a 409 conflict (conflictingTypeId in the error details) when another live type already owns the resulting identity — that means these are duplicates: call admin_preview_plant_type_merge and merge them in the app instead of forcing this edit. Refuses to turn a type with cultivar children into a cultivar itself. note is required and recorded on the change log. Requires an admin-scoped API key.", {
416
+ id: z.number().int().describe("Global plant type id."),
417
+ genus: z.string().nullable().describe("New genus, or null to clear it (only valid when the type still has a name to fall back on as its identity)."),
418
+ species: z.string().nullable().describe("New species epithet, or null."),
419
+ cultivar: z.string().nullable().describe("New cultivar name, or null."),
420
+ note: z.string().min(1).describe("Required note, recorded on the change log."),
421
+ }, async ({ id, genus, species, cultivar, note }) => apiSend("PATCH", `/api/v1/admin/plant-types/${id}/identity`, { genus, species, cultivar, note }));
422
+ server.tool("admin_verify_plant_type", "ADMIN WRITE: promote an UNVERIFIED global plant type to VERIFIED. note is required and recorded on the change log. Requires an admin-scoped API key.", {
423
+ id: z.number().int().describe("Global plant type id."),
424
+ note: z.string().min(1).describe("Required note, recorded on the change log."),
425
+ }, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/${id}/verify`, { note }));
426
+ const catalogFieldChoices = z
427
+ .record(z.string(), z.enum(["source", "target"]))
428
+ .describe("Keyed by field name — picks which side wins for a catalog field admin_preview_plant_type_merge lists as conflict/onlySource. Omit a field (or omit this whole argument) to take the plan's own default for it.");
429
+ server.tool("admin_merge_plant_types", "ADMIN WRITE: merge sourceId into targetId in the shared global plant type catalog. This is IRREVERSIBLE: sourceId becomes a MERGED redirect stub, and every plant, name, image, cultivar child, pending proposal, legacy mapping, and open duplicate candidate that pointed at it moves onto targetId. Always call admin_preview_plant_type_merge first — it shows the exact same comparison a human admin would see (both sides' values, the per-field carry-over plan and its defaults, counts that would move, and why a direction might be disallowed) before you commit to this. targetId keeps its own identity, parentId, status, and cover image unchanged; fieldChoices only picks which side's value wins for the catalog fields the preview lists as conflict or onlySource — everything else the plan defaults to adopting happens regardless of fieldChoices. Returns the survivor's full detail (same shape as admin_get_plant_type) plus the move counts. note is required and recorded on the change log of both types. Requires an admin-scoped API key.", {
430
+ sourceId: z.number().int().describe("The type that will be merged away (becomes a MERGED redirect)."),
431
+ targetId: z.number().int().describe("The type that will survive and receive everything from sourceId."),
432
+ note: z.string().min(1).describe("Required note, recorded on the change log of both types."),
433
+ fieldChoices: catalogFieldChoices.optional(),
434
+ }, async ({ sourceId, targetId, note, fieldChoices }) => apiSend("POST", "/api/v1/admin/plant-types/merge", { sourceId, targetId, note, fieldChoices }));
435
+ server.tool("admin_accept_plant_type_proposal", "ADMIN WRITE: accept a pending proposal against a global plant type, in full or in part. Omitting fields accepts every field the proposal changed (status ACCEPTED); passing an explicit subset accepts only those (status PARTIAL, unless the subset happens to cover everything proposed). If a field's current value has moved on since the proposal was filed, accepting it fails the whole call with a 409 naming the stale fields (details.staleFields) — there is no override here; either handle that field by hand (e.g. admin_update_plant_type_fields) and retry with fields excluding it, or deny the proposal instead. Returns the type's full detail (post-accept) plus which fields actually changed. note is required and recorded on the change log. Requires an admin-scoped API key.", {
436
+ id: z.number().int().describe("Proposal id, from admin_list_plant_type_proposals."),
437
+ fields: z
438
+ .array(z.string())
439
+ .optional()
440
+ .describe("Subset of the proposal's changed fields to accept. Omit to accept all of them."),
441
+ note: z.string().min(1).describe("Required note, recorded on the change log."),
442
+ }, async ({ id, fields, note }) => apiSend("POST", `/api/v1/admin/plant-types/proposals/${id}/accept`, { fields, note }));
443
+ server.tool("admin_deny_plant_type_proposal", "ADMIN WRITE: deny a pending proposal against a global plant type. Never writes a change log entry on the type itself — a denied proposal is private to its author and the reviewer — and never touches the type's own field values. note is required. Requires an admin-scoped API key.", {
444
+ id: z.number().int().describe("Proposal id, from admin_list_plant_type_proposals."),
445
+ note: z.string().min(1).describe("Required note (recorded on the proposal, not the type's change log)."),
446
+ }, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/proposals/${id}/deny`, { note }));
447
+ server.tool("admin_deny_plant_type_proposals", "ADMIN WRITE: deny every PENDING proposal on one global plant type at once, optionally narrowed to one origin — useful for clearing a batch of migration-filed proposals in one call instead of denying them one at a time. A proposal that stopped being PENDING between the lookup and its turn is silently skipped rather than failing the whole call; every other failure aborts the batch. note is required, applied to every denial. Requires an admin-scoped API key.", {
448
+ typeId: z.number().int().describe("Global plant type id."),
449
+ origin: z.enum(["user", "migration"]).optional().describe("Only deny proposals filed by this origin. Omit to deny every PENDING proposal regardless of origin."),
450
+ note: z.string().min(1).describe("Required note, applied to every denial."),
451
+ }, async ({ typeId, origin, note }) => apiSend("POST", "/api/v1/admin/plant-types/proposals/deny", { typeId, origin, note }));
452
+ server.tool("admin_dismiss_duplicate_candidate", "ADMIN WRITE: dismiss an OPEN duplicate candidate as 'not actually a duplicate', without merging it. It never comes back — a previously dismissed pair is excluded from every future admin_rescan_duplicate_candidates run. note is required for consistency with every other admin write here, but is NOT stored anywhere: the duplicate-candidate row has no field to keep it in. Requires an admin-scoped API key.", {
453
+ id: z.number().int().describe("Duplicate candidate id, from admin_list_duplicate_candidates."),
454
+ note: z.string().min(1).describe("Required, but not persisted — see the tool description."),
455
+ }, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/duplicates/${id}/dismiss`, { note }));
456
+ 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", {}));
457
+ }
458
+ /**
459
+ * Calls `/api/v1/me` once at startup to decide whether this key carries the
460
+ * `admin` scope, and registers the admin catalog tools only when it does —
461
+ * so a non-admin key's session never even sees a tool it could not call
462
+ * (rather than registering it and letting every call fail with 403). A
463
+ * failed whoami call (network error, invalid key — `apiGet`'s own retry-free
464
+ * failure modes) is logged to stderr and registers no admin tools, the same
465
+ * as a confirmed non-admin key: better to under-register than to guess.
466
+ */
467
+ async function registerAdminToolsIfPermitted() {
468
+ let res;
469
+ try {
470
+ res = await fetch(new URL(`${API_URL}/api/v1/me`), {
471
+ headers: { Authorization: `Bearer ${API_KEY}`, Accept: "application/json" },
472
+ });
473
+ }
474
+ catch (err) {
475
+ console.error(`[plants-mcp] Could not reach ${API_URL}/api/v1/me to check for admin access: ${String(err)}. Admin tools not registered.`);
476
+ return;
477
+ }
478
+ if (!res.ok) {
479
+ console.error(`[plants-mcp] /api/v1/me returned HTTP ${res.status}; admin tools not registered.`);
480
+ return;
481
+ }
482
+ let body;
483
+ try {
484
+ body = await res.json();
485
+ }
486
+ catch {
487
+ console.error("[plants-mcp] /api/v1/me returned an unreadable response; admin tools not registered.");
488
+ return;
489
+ }
490
+ const scopes = body?.scopes;
491
+ if (Array.isArray(scopes) && scopes.includes("admin")) {
492
+ registerAdminTools();
493
+ }
494
+ }
328
495
  // --- Boot -------------------------------------------------------------------
329
496
  async function main() {
497
+ await registerAdminToolsIfPermitted();
330
498
  const transport = new StdioServerTransport();
331
499
  await server.connect(transport);
332
500
  console.error(`[plants-mcp] v${VERSION} connected. API base: ${API_URL}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sebamomann/plants-mcp",
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.",
3
+ "version": "1.9.0",
4
+ "description": "MCP server for the Sprig plant app: 50 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. 17 more tools read, edit, merge and review the shared global plant type catalog for an admin-scoped key.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "sebamomann <github@sebamomann.de>",