@sebamomann/plants-mcp 3.0.0 → 3.1.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 +11 -0
- package/README.md +25 -6
- package/dist/app/_lib/mcp/apiClient.js +103 -0
- package/dist/app/_lib/mcp/tools.js +573 -0
- package/dist/app/_lib/mcp/version.js +7 -0
- package/dist/index.js +1 -605
- package/dist/mcp/package.json +57 -0
- package/dist/mcp/src/index.js +36 -0
- package/package.json +4 -7
package/dist/index.js
CHANGED
|
@@ -1,606 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { createApiClient } from "./apiClient.js";
|
|
7
|
-
/**
|
|
8
|
-
* MCP server for the Sprig plant app: reads the collection, and — with a
|
|
9
|
-
* write-scoped key — logs care, edits a plant, and deletes a watering or
|
|
10
|
-
* fertilization event. Plants and photos stay UI-only. An admin-scoped key
|
|
11
|
-
* additionally sees tools over the shared global plant type catalog
|
|
12
|
-
* (`admin_*`, registered conditionally — see `registerAdminToolsIfPermitted`):
|
|
13
|
-
* reads, creating and editing catalog entries directly, and (since this
|
|
14
|
-
* increment) merging duplicate types and reviewing proposals and duplicate
|
|
15
|
-
* candidates.
|
|
16
|
-
*
|
|
17
|
-
* Every tool is a thin proxy over the app's `/api/v1/*` REST endpoints,
|
|
18
|
-
* authenticated with a per-user API key sent as `Authorization: Bearer <key>`.
|
|
19
|
-
* Tools forward their arguments as query params and return the API's JSON
|
|
20
|
-
* verbatim, so they stay correct even if the API response shape evolves.
|
|
21
|
-
*/
|
|
22
|
-
/**
|
|
23
|
-
* Single source of truth for the advertised version: package.json, so a release
|
|
24
|
-
* bump cannot leave the MCP handshake reporting a stale number. `../package.json`
|
|
25
|
-
* resolves to the package root both from `dist/index.js` (published) and from
|
|
26
|
-
* `src/index.ts` (tsx dev).
|
|
27
|
-
*/
|
|
28
|
-
const { version: VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
29
|
-
const API_URL = (process.env.PLANT_API_URL ?? "http://localhost:3000").replace(/\/+$/, "");
|
|
30
|
-
const API_KEY = process.env.PLANT_API_KEY;
|
|
31
|
-
if (!API_KEY) {
|
|
32
|
-
console.error("[plants-mcp] Missing PLANT_API_KEY environment variable.");
|
|
33
|
-
process.exit(1);
|
|
34
|
-
}
|
|
35
|
-
const { apiGet, apiSend } = createApiClient({ apiUrl: API_URL, apiKey: API_KEY });
|
|
36
|
-
const server = new McpServer({ name: "plants-mcp", version: VERSION });
|
|
37
|
-
// --- Identity ---------------------------------------------------------------
|
|
38
|
-
server.tool("whoami", "Return the authenticated user for the configured API key.", async () => apiGet("/api/v1/me"));
|
|
39
|
-
// --- Plants -----------------------------------------------------------------
|
|
40
|
-
server.tool("list_plants", "List the user's plants, with optional filters. Returns plant summaries.", {
|
|
41
|
-
status: z
|
|
42
|
-
.enum(["LIVING", "DEAD", "GIFTED", "LOST", "SOLD", "TRADED", "MERGED", "SPLIT", "EXTERNAL"])
|
|
43
|
-
.optional()
|
|
44
|
-
.describe("Filter by plant status. EXTERNAL is a reference plant: somebody else's plant, recorded only as an anchor for propagation lineage — never cared for and not part of the collection's totals."),
|
|
45
|
-
lifecycle: z.enum(["PROPAGATING", "ESTABLISHED"]).optional().describe("Filter by lifecycle stage."),
|
|
46
|
-
locationId: z.number().int().optional().describe("Filter by location id."),
|
|
47
|
-
plantTypeId: z.number().int().optional().describe("Filter by global plant type id, from list_plant_types/search_plant_types/get_plant."),
|
|
48
|
-
soilId: z.number().int().optional().describe("Filter by soil id."),
|
|
49
|
-
fertilizerId: z.number().int().optional().describe("Filter by fertilizer id."),
|
|
50
|
-
tag: z.string().optional().describe("Filter by exact tag (case-sensitive)."),
|
|
51
|
-
search: z.string().optional().describe("Free-text search over the plant's nickname, its type's names, and notes."),
|
|
52
|
-
limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
|
|
53
|
-
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
54
|
-
sort: z.enum(["displayName", "createdAt", "updatedAt", "acquiredAt"]).optional().describe("Sort key. 'displayName' sorts by the plant's resolved name (nickname, or its type's name); the rest sort newest first."),
|
|
55
|
-
}, async (args) => apiGet("/api/v1/plants", args));
|
|
56
|
-
server.tool("get_plant", "Get one plant with full detail: identity (type, nickname, display name, botanical name, unidentified state — see 'type'), typeFacts (the type's effective light/water/feeding need, toxicity, climate, growth, substrate/propagation, family/native region — using the catalog field keys from get_plant_type), catalogs (location, soil, fertilizer), care config, and recent event summaries. The six schedule fields (wateringFrequencySummerDays/WinterDays, wateringFrequencySummer/Winter, fertilizingCycleSummerWeeks/WinterWeeks) are each { value, source: 'plant'|'type'|'none', typeValue } — value is what scheduling/display use, source says where it came from, typeValue is what the type would say even when the plant overrides it. For a plant in a controlled-climate location (location.controlledClimate) that has not set keepWinterCare, followsSummerYearRound is true and the three winter schedule fields show the SUMMER values — the plant follows its summer cycle all year (the raw stored winter values are kept and return when keepWinterCare is set or the plant moves elsewhere).", { id: z.number().int().describe("Plant id.") }, async ({ id }) => apiGet(`/api/v1/plants/${id}`));
|
|
57
|
-
// --- Events connected to a plant -------------------------------------------
|
|
58
|
-
const eventArgs = {
|
|
59
|
-
id: z.number().int().describe("Plant id."),
|
|
60
|
-
limit: z.number().int().min(1).max(500).optional().describe("Max results (pagination)."),
|
|
61
|
-
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
62
|
-
};
|
|
63
|
-
server.tool("list_watering_events", "List watering events for a plant, most recent first.", eventArgs, async ({ id, ...q }) => apiGet(`/api/v1/plants/${id}/watering-events`, q));
|
|
64
|
-
server.tool("list_fertilization_events", "List fertilization events for a plant, most recent first.", eventArgs, async ({ id, ...q }) => apiGet(`/api/v1/plants/${id}/fertilization-events`, q));
|
|
65
|
-
server.tool("list_care_events", "List the combined care timeline for a plant (watering, fertilization, refill, hydro, potting, snoozes), each tagged with a kind, most recent first.", eventArgs, async ({ id, ...q }) => apiGet(`/api/v1/plants/${id}/events`, q));
|
|
66
|
-
server.tool("list_photos", "List photo metadata for a plant (urls, takenAt), most recent first.", eventArgs, async ({ id, ...q }) => apiGet(`/api/v1/plants/${id}/photos`, q));
|
|
67
|
-
server.tool("list_health_entries", "List health and AI-analysis entries for a plant, most recent first.", eventArgs, async ({ id, ...q }) => apiGet(`/api/v1/plants/${id}/health`, q));
|
|
68
|
-
server.tool("list_diagnoses", "List past guided problem-diagnosis runs for a plant ('Problem eingrenzen' in the app), most recent first: the symptom, every question with the answer given, the ranked possible causes, the chosen action, and the AI second opinion if one was requested. Resolved to plain English text. Read-only — the guided flow itself is a UI-only experience; use add_health_entry to log an observation directly instead.", eventArgs, async ({ id, ...q }) => apiGet(`/api/v1/plants/${id}/diagnoses`, q));
|
|
69
|
-
// --- Care schedule (calendar) ----------------------------------------------
|
|
70
|
-
/**
|
|
71
|
-
* Derived schedule state, computed server-side from the same helpers the app's
|
|
72
|
-
* own calendar uses. Prefer these over reconstructing due dates from raw event
|
|
73
|
-
* lists: they account for season, care mode, and snoozes.
|
|
74
|
-
*/
|
|
75
|
-
const careArgs = {
|
|
76
|
-
date: z
|
|
77
|
-
.string()
|
|
78
|
-
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
79
|
-
.optional()
|
|
80
|
-
.describe("Day to evaluate against, as YYYY-MM-DD. Defaults to today."),
|
|
81
|
-
season: z
|
|
82
|
-
.enum(["summer", "winter"])
|
|
83
|
-
.optional()
|
|
84
|
-
.describe("Override the season (Apr–Sep is summer). Defaults to the season of `date`."),
|
|
85
|
-
locationId: z.number().int().optional().describe("Only consider plants in this location."),
|
|
86
|
-
};
|
|
87
|
-
server.tool("list_due_care", "What needs water or fertilizer now: overdue work plus anything due on the given day. Use `windowDays` to look ahead. This is the right tool for 'what should I do today?'. A fertilizing entry carries `wateringAlignedOn` (YYYY-MM-DD, null when absent) when the app's calendar has grouped that feeding with a watering; `windowDays`/`includeOverdue` are judged by that day, not the feeding's own `nextDue` — see `get_care_calendar`'s description for the full rule. The additive `checks` array lists autumn 'Prüfen' questions — { plantId, name, lastWateredAt, checkSince, regularDueAt } — for plants that dry out at their summer pace while the season change has stretched their interval: the summer-pace date (`checkSince`) has passed but the regular due date (`regularDueAt`) has not. A check is a question ('is the soil dry?'), not a task — it is not in `tasks`/`counts`. Answer 'dry' with `record_watering`, 'still moist' with `dismiss_transition_check`.", {
|
|
88
|
-
...careArgs,
|
|
89
|
-
windowDays: z
|
|
90
|
-
.number()
|
|
91
|
-
.int()
|
|
92
|
-
.min(0)
|
|
93
|
-
.max(60)
|
|
94
|
-
.optional()
|
|
95
|
-
.describe("Also include work due within this many days after `date`. 0 (default) = that day only."),
|
|
96
|
-
includeOverdue: z
|
|
97
|
-
.boolean()
|
|
98
|
-
.optional()
|
|
99
|
-
.describe("Include already-overdue work (default true)."),
|
|
100
|
-
}, async (args) => apiGet("/api/v1/care/due", { ...args, includeOverdue: args.includeOverdue?.toString() }));
|
|
101
|
-
server.tool("list_overdue_care", "Only work whose due date has already passed, most overdue first, with how many days each is late. Use for 'what have I fallen behind on?'. A feeding waiting on a future watering (`wateringAlignedOn` set — see `get_care_calendar`) is not listed here even when its own `nextDue` has passed: it's due on the watering's day, not neglected.", careArgs, async (args) => apiGet("/api/v1/care/overdue", args));
|
|
102
|
-
server.tool("get_care_calendar", "Day-by-day care schedule over a date range, plus an overdue group. Each plant's watering appears on its next due day only — this projects the current schedule rather than simulating future waterings. Fertilizing follows the app calendar's water-align rule: a feeding due before a plant's next watering is bucketed there unconditionally; one due later is bucketed with whichever of the plant's watering days (projected forward from its rhythm) is nearest — pulled onto the earlier one within a third of the watering interval (capped at a quarter of the feeding's own cycle), otherwise held for the later one (capped at half the feeding's own cycle) — so a fertilizer-only day never appears when a watering is close. Either way the fertilizing entry's `wateringAlignedOn` (YYYY-MM-DD) names that day and the bucket it's placed in follows it, while `nextDue` keeps the feeding's own, unchanged due date. A plant with no watering schedule (reservoir/hydro) has no watering to attach a feeding to, so its `wateringAlignedOn` is always null and the feeding stays on its own due date.", {
|
|
103
|
-
...careArgs,
|
|
104
|
-
days: z.number().int().min(1).max(60).optional().describe("Number of days to project (default 14, max 60)."),
|
|
105
|
-
}, async (args) => apiGet("/api/v1/care/calendar", args));
|
|
106
|
-
server.tool("list_care_recommendations", "Care problems detected across the collection: watering-cycle mismatches, chronic lateness, missed seasonal fertilizing, a fertilizer pause right after repotting, repotting overdue (2+ years, or never), stale photos. Returns i18n message keys plus their values, not rendered text. Dismissed recommendations are excluded.", async () => apiGet("/api/v1/care/recommendations"));
|
|
107
|
-
// --- Collection-wide activity ----------------------------------------------
|
|
108
|
-
server.tool("list_recent_activity", "What was actually done across the whole collection, newest first: waterings, fertilizations, refills, hydro events, repottings, health notes, photos. Each entry is tagged with a kind and names its plant. Use this instead of calling the per-plant event tools in a loop when the question is about a time period ('what did I water last week?') rather than one plant. Does not include acquisitions, gifts, sales, or trades.", {
|
|
109
|
-
since: z.string().optional().describe("Only events at or after this ISO date/timestamp, e.g. '2026-07-01'."),
|
|
110
|
-
until: z.string().optional().describe("Only events at or before this ISO date/timestamp."),
|
|
111
|
-
kinds: z
|
|
112
|
-
.array(z.enum(["watering", "fertilization", "refill", "hydro", "potting", "health", "photo"]))
|
|
113
|
-
.optional()
|
|
114
|
-
.describe("Restrict to these event kinds. Defaults to all of them."),
|
|
115
|
-
limit: z.number().int().min(1).max(500).optional().describe("Max results (pagination)."),
|
|
116
|
-
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
117
|
-
}, async ({ kinds, ...rest }) => apiGet("/api/v1/activity", { ...rest, kinds: kinds?.join(",") }));
|
|
118
|
-
// --- Collection-wide aggregates ---------------------------------------------
|
|
119
|
-
server.tool("collection_stats", "Counts and totals across the whole collection: how many plants by status (living, dead, gifted, sold, traded), by location, and by plant type; total spend (sum of price) and total revenue (sum of soldPrice for sold plants); acquisitions bucketed by month. Use this instead of listing every plant when the question is a count or a total, not which specific plants.", async () => apiGet("/api/v1/collection/stats"));
|
|
120
|
-
server.tool("sitter_briefing", "Printable care sheet for the caller's current or next sitter-covered vacation: per-plant sitter instructions, watering notes, watering cycle and next-due state, plus the day-by-day watering schedule for the trip. Returns { hasVacation: false } when there is no upcoming or running vacation in 'sitter' mode. Read-only — does not create or modify a vacation.", {
|
|
121
|
-
locationId: z.number().int().optional().describe("Only include plants in this location (id from list_locations). Omit for every plant on the vacation."),
|
|
122
|
-
}, async (args) => apiGet("/api/v1/vacation/sitter-briefing", args));
|
|
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"));
|
|
126
|
-
// --- Care logging (writes) --------------------------------------------------
|
|
127
|
-
/**
|
|
128
|
-
* Mutating tools. These require an API key with the `write` scope; a read-only
|
|
129
|
-
* key returns HTTP 403. They are idempotent per day — a plant already
|
|
130
|
-
* watered/fertilized on the target day is skipped, not logged twice — and
|
|
131
|
-
* accept many plants at once, so prefer one bulk call over a loop.
|
|
132
|
-
*/
|
|
133
|
-
const optionalPlantDate = (label) => z
|
|
134
|
-
.string()
|
|
135
|
-
.regex(/^\d{4}-\d{2}-\d{2}/)
|
|
136
|
-
.optional()
|
|
137
|
-
.describe(`When it happened, ISO date (YYYY-MM-DD). Defaults to now. Cannot be in the future. ${label}`);
|
|
138
|
-
server.tool("record_watering", "WRITE: log a watering for one or more plants. Reservoir/hydro plants are recorded as a refill/top-up automatically, and plants that fertilize with watering also get a fertilization logged. Plants already watered that day are skipped. Requires a write-scoped API key.", {
|
|
139
|
-
plantIds: z.array(z.number().int().positive()).min(1).max(200).describe("Plant ids to water."),
|
|
140
|
-
wateredAt: optionalPlantDate(""),
|
|
141
|
-
}, async ({ plantIds, wateredAt }) => apiSend("POST", "/api/v1/care/watering", { plantIds, wateredAt }));
|
|
142
|
-
server.tool("record_fertilization", "WRITE: log a fertilization for one or more plants. Fertilizer goes into the watering can, so each fertilized plant also gets that day's watering logged (a refill for reservoir plants, a top-up for hydro) unless one already exists — reported as alsoWatered; do not call record_watering for the same plants afterwards. fertilizerId and fertilizerPercent default to each plant's own settings when omitted. Plants already fertilized that day are skipped entirely. Requires a write-scoped API key.", {
|
|
143
|
-
plantIds: z.array(z.number().int().positive()).min(1).max(200).describe("Plant ids to fertilize."),
|
|
144
|
-
fertilizerId: z.number().int().positive().optional().describe("Override fertilizer; defaults to each plant's own."),
|
|
145
|
-
fertilizerPercent: z
|
|
146
|
-
.number()
|
|
147
|
-
.int()
|
|
148
|
-
.min(0)
|
|
149
|
-
.max(1000)
|
|
150
|
-
.optional()
|
|
151
|
-
.describe("Strength as a percentage of the base dose; defaults to each plant's own."),
|
|
152
|
-
fertilizedAt: optionalPlantDate(""),
|
|
153
|
-
}, async (args) => apiSend("POST", "/api/v1/care/fertilization", { ...args }));
|
|
154
|
-
server.tool("record_repotting", "WRITE: log a repotting — new pot, and optionally a new soil mix and/or pot size. Re-derives the plant's current soil from its latest potting event, and syncs watering/fertilizing mode when the new pot's self-watering flag changes it (the same way the app's own repotting form does). pottedAt defaults to today, or to the plant's acquiredAt date if this is its very first-ever potting event. Same-day guard: if the plant already has a potting event on the resolved pottedAt day, this fails with a 409 instead of creating a second one — pass confirmDuplicate: true to log a genuine second repotting on the same day anyway. Requires a write-scoped API key.", {
|
|
155
|
-
plantId: z.number().int().positive().describe("Plant id."),
|
|
156
|
-
potId: z.number().int().positive().describe("Pot id, from list_pots (the caller's own pots, plus shared defaults)."),
|
|
157
|
-
potSizeId: z.number().int().positive().optional().describe("One of the pot's own recorded sizes, from that pot's row in list_pots."),
|
|
158
|
-
soilId: z.number().int().positive().optional().describe("Soil mix id, from list_soils."),
|
|
159
|
-
pottedAt: optionalPlantDate("Defaults to today, or to the plant's acquiredAt for its first-ever potting event."),
|
|
160
|
-
hasDrainage: z.boolean().optional().describe("Whether the pot has a drainage hole."),
|
|
161
|
-
hasClimbingAid: z.boolean().optional().describe("Whether a climbing aid (moss pole, trellis) was added."),
|
|
162
|
-
notes: z.string().max(2000).optional().describe("Free-text notes about this repotting."),
|
|
163
|
-
confirmDuplicate: z
|
|
164
|
-
.boolean()
|
|
165
|
-
.optional()
|
|
166
|
-
.describe("Pass true to log a second potting event on a day that already has one — otherwise that call fails with a 409."),
|
|
167
|
-
}, async ({ plantId, ...body }) => apiSend("POST", `/api/v1/plants/${plantId}/repotting`, body));
|
|
168
|
-
server.tool("record_refill", "WRITE: log a reservoir refill for a reservoir-mode plant — the dedicated tool for logging the fertilizer/percent that go with it (record_watering already routes a plain watering to a refill for these plants, but without those fields). fertilizerId/fertilizerPercent default to the plant's own settings when omitted. When the plant fertilizes with every watering, also logs a matching fertilization. Requires a write-scoped API key.", {
|
|
169
|
-
plantId: z.number().int().positive().describe("Plant id."),
|
|
170
|
-
fertilizerId: z.number().int().positive().optional().describe("Override fertilizer; defaults to the plant's own."),
|
|
171
|
-
fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose; defaults to the plant's own."),
|
|
172
|
-
refilledAt: optionalPlantDate(""),
|
|
173
|
-
}, async ({ plantId, ...body }) => apiSend("POST", `/api/v1/plants/${plantId}/refill`, body));
|
|
174
|
-
server.tool("record_hydro_event", "WRITE: log a hydro-culture event for a hydro-mode plant — a water top-up (default) or a full solution change. waterLevel (0-100) and the fertilizer fields are optional for either kind. Requires a write-scoped API key.", {
|
|
175
|
-
plantId: z.number().int().positive().describe("Plant id."),
|
|
176
|
-
kind: z.enum(["topup", "change"]).optional().describe("'topup' (default) or 'change' (full solution change)."),
|
|
177
|
-
waterLevel: z.number().int().min(0).max(100).optional().describe("Reservoir fill level as a percentage."),
|
|
178
|
-
fertilizerId: z.number().int().positive().optional().describe("Override fertilizer; defaults to the plant's own."),
|
|
179
|
-
fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose; defaults to the plant's own."),
|
|
180
|
-
recordedAt: optionalPlantDate(""),
|
|
181
|
-
notes: z.string().max(2000).optional().describe("Free-text notes."),
|
|
182
|
-
}, async ({ plantId, ...body }) => apiSend("POST", `/api/v1/plants/${plantId}/hydro`, body));
|
|
183
|
-
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). Moving a WATERING past a feeding that was due before it moves that feeding onto the same day too — fertilizer goes into the watering can, so a feeding left in between would have nothing to deliver it; that second snooze comes back as fertRider (null when nothing moved), and an already-overdue feeding is left where it is. Requires a write-scoped API key.", {
|
|
184
|
-
plantId: z.number().int().positive().describe("Plant id."),
|
|
185
|
-
careType: z.enum(["water", "fert"]).describe("Which schedule to postpone."),
|
|
186
|
-
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."),
|
|
187
|
-
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."),
|
|
188
|
-
originalDueAt: z.string().describe("The due date being pushed out, e.g. from list_due_care's nextDue for this plant/careType."),
|
|
189
|
-
note: z.string().max(160).optional().describe("Free-text reason, e.g. 'away for the week'."),
|
|
190
|
-
}, async (args) => apiSend("POST", "/api/v1/care/snooze", { ...args }));
|
|
191
|
-
server.tool("update_plant_care", "WRITE: edit a plant's care schedule — watering mode, days/labels, fertilizing mode, cycles and percent, and the watering/fertilizing notes. Omitted fields keep their current value. null on wateringFrequencySummerDays/WinterDays, wateringFrequencySummer/Winter, or fertilizingCycleSummerWeeks/WinterWeeks means 'follow the type' — the plant's effective schedule then falls back to its type's suggested value (see get_plant's typeFacts / the schedule fields' typeValue) instead of having no schedule at all; a value equal to the type's suggested value is stored the same way (it follows the type). null on wateringNotes/fertilizingNotes just clears the note. Watering fields are ignored while the plant's watering isn't on a schedule (reservoir/hydro), and the fertilizing-cycle fields are ignored while it fertilizes with every watering — there's no cycle to configure in either case. If wateringMode/fertilizingMode is part of this same call, that check runs against the NEW mode, not the plant's current one — e.g. switching to 'reservoir' while also passing a watering cycle ignores that cycle. sunRequirement/waterRequirement are no longer settable here — light and water need are type-only facts now (get_plant's typeFacts, or get_plant_type). Use this (not update_plant) to fix a 'wateringCycleMismatch' recommendation where the mode itself is wrong. keepWinterCare brings the winter cycle back for a plant in a controlled-climate location (which otherwise follows its summer cycle all year, see get_plant's followsSummerYearRound) — it governs both watering and fertilizing and has no effect elsewhere; the stored winter values are never cleared by it. Requires a write-scoped API key.", {
|
|
192
|
-
plantId: z.number().int().positive().describe("Plant id."),
|
|
193
|
-
wateringMode: z.enum(["scheduled", "reservoir", "hydro"]).optional().describe("How this plant is watered: on a schedule, from a reservoir, or in water/hydro culture."),
|
|
194
|
-
fertilizingMode: z.enum(["scheduled", "with_watering"]).optional().describe("How this plant is fertilized: on its own cycle, or automatically with every watering."),
|
|
195
|
-
wateringFrequencySummer: z.string().max(50).nullable().optional().describe("Free-text summer watering label, e.g. 'twice a week'. null follows the type's guidance. Ignored unless watering is scheduled."),
|
|
196
|
-
wateringFrequencySummerDays: z.number().int().min(1).max(365).nullable().optional().describe("Summer watering interval in days. null follows the type's suggested days. Ignored unless watering is scheduled."),
|
|
197
|
-
wateringFrequencyWinter: z.string().max(50).nullable().optional().describe("Free-text winter watering label. null follows the type's guidance. Ignored unless watering is scheduled."),
|
|
198
|
-
wateringFrequencyWinterDays: z.number().int().min(1).max(365).nullable().optional().describe("Winter watering interval in days. null follows the type's suggested days. Ignored unless watering is scheduled."),
|
|
199
|
-
wateringNotes: z.string().max(500).nullable().optional().describe("Free-text watering notes. Ignored unless watering is scheduled."),
|
|
200
|
-
fertilizingCycleSummerWeeks: z.number().int().min(0).max(52).nullable().optional().describe("Summer fertilizing interval in weeks. null follows the type's suggested weeks. Ignored while fertilizing is 'with every watering'."),
|
|
201
|
-
fertilizingCycleWinterWeeks: z.number().int().min(0).max(52).nullable().optional().describe("Winter fertilizing interval in weeks. null follows the type's suggested weeks. Ignored while fertilizing is 'with every watering'."),
|
|
202
|
-
fertilizerPercent: z.number().int().min(0).max(1000).optional().describe("Strength as a percentage of the base dose."),
|
|
203
|
-
fertilizingNotes: z.string().max(500).nullable().optional().describe("Free-text fertilizing notes."),
|
|
204
|
-
keepWinterCare: z.boolean().optional().describe("true: use the winter cycle despite a controlled-climate location; false: follow the summer cycle all year there. Only matters while the plant stands in a location with controlledClimate."),
|
|
205
|
-
}, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}/care`, body));
|
|
206
|
-
server.tool("update_plant", "WRITE: edit a plant's type, nickname, 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 — and for plantTypeId, makes the plant 'unidentified'. nickname is required whenever the plant's state after this edit has no type (whether because plantTypeId is set to null here, or was already null and nickname isn't being set to something non-empty) — see plantIdentity's validatePlantNickname. Requires a write-scoped API key.", {
|
|
207
|
-
plantId: z.number().int().positive().describe("Plant id."),
|
|
208
|
-
plantTypeId: z.number().int().positive().nullable().optional().describe("New global plant type id, from list_plant_types/search_plant_types/get_plant. A MERGED id transparently resolves to its survivor. null makes the plant unidentified — nickname is then required."),
|
|
209
|
-
nickname: z.string().max(100).nullable().optional().describe("The plant's own name, shown instead of the type's name when set. Required when the plant has (or is becoming) unidentified."),
|
|
210
|
-
locationId: z.number().int().positive().nullable().optional().describe("New location id, from list_locations. null unassigns it."),
|
|
211
|
-
soilId: z.number().int().positive().nullable().optional().describe("New soil id, from list_soils. null unassigns it."),
|
|
212
|
-
fertilizerId: z.number().int().positive().nullable().optional().describe("New fertilizer id, from list_fertilizers. null unassigns it."),
|
|
213
|
-
quantity: z.number().int().min(0).max(9999).nullable().optional().describe("How many plants this row represents."),
|
|
214
|
-
notes: z.string().max(5000).nullable().optional().describe("Free-text notes."),
|
|
215
|
-
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."),
|
|
216
|
-
sitterInstructions: z.string().max(500).nullable().optional().describe("Care instructions shown on a sitter link."),
|
|
217
|
-
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."),
|
|
218
|
-
}, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}`, body));
|
|
219
|
-
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.", {
|
|
220
|
-
plantId: z.number().int().positive().describe("Plant id."),
|
|
221
|
-
kind: z.enum(["observation", "issue"]).describe("'observation' for a neutral note, 'issue' for a problem."),
|
|
222
|
-
text: z.string().min(1).max(1000).describe("The note text."),
|
|
223
|
-
category: z.string().max(80).optional().describe("Optional short label, e.g. 'pests', 'growth'."),
|
|
224
|
-
severity: z.enum(["low", "medium", "high"]).optional().describe("For issues: how serious."),
|
|
225
|
-
}, async ({ plantId, kind, text, category, severity }) => apiSend("POST", `/api/v1/plants/${plantId}/health`, { kind, text, category, severity }));
|
|
226
|
-
server.tool("update_health_entry", "WRITE: edit an existing health entry's content. Omitted fields keep their current value. Takes the entry id from list_health_entries, not a plant id. Requires a write-scoped API key.", {
|
|
227
|
-
entryId: z.number().int().positive().describe("Health entry id, from list_health_entries."),
|
|
228
|
-
kind: z.enum(["observation", "issue"]).optional().describe("Change the entry kind."),
|
|
229
|
-
text: z.string().min(1).max(1000).optional().describe("Replacement note text."),
|
|
230
|
-
category: z.string().max(80).optional().describe("Replacement short label, e.g. 'pests'."),
|
|
231
|
-
severity: z.enum(["low", "medium", "high"]).optional().describe("For issues: how serious."),
|
|
232
|
-
}, async ({ entryId, ...body }) => apiSend("PATCH", `/api/v1/health-entries/${entryId}`, body));
|
|
233
|
-
server.tool("resolve_health_entry", "WRITE: mark a health entry resolved (the issue is over), or reopen a resolved one by passing resolved=false. Reversible either way. Takes the entry id from list_health_entries. Requires a write-scoped API key.", {
|
|
234
|
-
entryId: z.number().int().positive().describe("Health entry id, from list_health_entries."),
|
|
235
|
-
resolved: z.boolean().default(true).describe("true to resolve, false to reopen."),
|
|
236
|
-
}, async ({ entryId, resolved }) => apiSend("PATCH", `/api/v1/health-entries/${entryId}`, { resolved }));
|
|
237
|
-
server.tool("dismiss_care_recommendation", "WRITE: hide one care recommendation, as the Improvements page does. Pass plantId, type, and fingerprint exactly as returned by list_care_recommendations — the fingerprint identifies this specific occurrence, so a later recurrence of the same problem still surfaces. Note 'wateringCycleMismatch' cannot be dismissed: it is a config contradiction, fixed by editing the plant. Requires a write-scoped API key.", {
|
|
238
|
-
plantId: z.number().int().positive().describe("Plant id the recommendation belongs to."),
|
|
239
|
-
type: z
|
|
240
|
-
.enum([
|
|
241
|
-
"wateringOftenLate",
|
|
242
|
-
"fertilizingOftenLate",
|
|
243
|
-
"fertilizingNotStarted",
|
|
244
|
-
"recentlyRepottedAvoidFertilizer",
|
|
245
|
-
"noRecentPhoto",
|
|
246
|
-
"propagationCheckDue",
|
|
247
|
-
"possibleDecline",
|
|
248
|
-
])
|
|
249
|
-
.describe("Recommendation type, from list_care_recommendations."),
|
|
250
|
-
fingerprint: z.string().min(1).describe("Occurrence fingerprint, from list_care_recommendations."),
|
|
251
|
-
}, async (args) => apiSend("POST", "/api/v1/care/recommendations/dismiss", { ...args }));
|
|
252
|
-
server.tool("dismiss_transition_check", "WRITE: answer an autumn 'Prüfen' check with 'noch feucht' (still moist) — hides that plant's check until its regular due date, without moving the due date. Pass the plantId of an entry in `checks` of list_due_care. The answer is tied to the plant's last watering, so a new watering reopens the question next autumn/interval. For the other answer — the soil is dry — just call `record_watering`: a normal watering makes the check disappear. Requires a write-scoped API key.", {
|
|
253
|
-
plantId: z.number().int().positive().describe("Plant id, from `checks` of list_due_care."),
|
|
254
|
-
}, async (args) => apiSend("POST", "/api/v1/care/transition-checks/dismiss", { ...args }));
|
|
255
|
-
// --- Deleting events ---------------------------------------------------------
|
|
256
|
-
/**
|
|
257
|
-
* The only destructive tools in this server. Everything else — plants,
|
|
258
|
-
* photos, catalog entries — stays UI-only; these two exist because a
|
|
259
|
-
* mis-logged watering or fertilization is otherwise stuck in the history
|
|
260
|
-
* forever (there's no "undo" once the day has passed).
|
|
261
|
-
*/
|
|
262
|
-
server.tool("delete_watering_event", "WRITE: delete a watering-type event from a plant's history. This is destructive and cannot be undone. For a reservoir/hydro plant this deletes its refill/top-up event instead — pass the id exactly as returned by list_watering_events or list_care_events. Requires a write-scoped API key.", {
|
|
263
|
-
plantId: z.number().int().positive().describe("Plant id."),
|
|
264
|
-
eventId: z.number().int().positive().describe("Event id, from list_watering_events or list_care_events."),
|
|
265
|
-
}, async ({ plantId, eventId }) => apiSend("DELETE", `/api/v1/plants/${plantId}/watering-events/${eventId}`));
|
|
266
|
-
server.tool("delete_fertilization_event", "WRITE: delete a fertilization event from a plant's history. This is destructive and cannot be undone. Pass the id exactly as returned by list_fertilization_events or list_care_events. Requires a write-scoped API key.", {
|
|
267
|
-
plantId: z.number().int().positive().describe("Plant id."),
|
|
268
|
-
eventId: z.number().int().positive().describe("Event id, from list_fertilization_events or list_care_events."),
|
|
269
|
-
}, async ({ plantId, eventId }) => apiSend("DELETE", `/api/v1/plants/${plantId}/fertilization-events/${eventId}`));
|
|
270
|
-
// --- Plant lifecycle (writes) ------------------------------------------------
|
|
271
|
-
server.tool("create_plant", "WRITE: add a new plant to the collection. plantTypeId is now optional (omit it, or pass null, to create an 'unidentified' plant) — call list_plant_types/search_plant_types first and pick the matching one when the species is known; creating a new plant type is not possible here and stays a UI action. nickname is required whenever plantTypeId is omitted/null, matching the app's plant form. 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.", {
|
|
272
|
-
plantTypeId: z.number().int().positive().nullable().optional().describe("Global plant type id, from list_plant_types/search_plant_types. Omit (or pass null) for an unidentified plant — a MERGED id transparently resolves to its survivor."),
|
|
273
|
-
nickname: z.string().max(100).optional().describe("The plant's own name. Required when plantTypeId is omitted or null."),
|
|
274
|
-
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."),
|
|
275
|
-
parentPlantId: z.number().int().positive().optional().describe("Optional: the plant this one was propagated from."),
|
|
276
|
-
}, async (body) => apiSend("POST", "/api/v1/plants", { ...body }));
|
|
277
|
-
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.", {
|
|
278
|
-
plantId: z.number().int().positive().describe("The parent plant the cuttings came from."),
|
|
279
|
-
count: z.number().int().min(1).max(10).optional().describe("How many propagations to create. Defaults to 1, max 10."),
|
|
280
|
-
}, async ({ plantId, count }) => apiSend("POST", `/api/v1/plants/${plantId}/propagations`, { count }));
|
|
281
|
-
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.", {
|
|
282
|
-
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."),
|
|
283
|
-
survivorPlantId: z.number().int().positive().optional().describe("An existing plant that absorbs the sources. Mutually exclusive with createNewFrom."),
|
|
284
|
-
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."),
|
|
285
|
-
}, async (body) => apiSend("POST", "/api/v1/plants/merge", { ...body }));
|
|
286
|
-
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.", {
|
|
287
|
-
plantId: z.number().int().positive().describe("The MERGED plant to restore."),
|
|
288
|
-
}, async ({ plantId }) => apiSend("POST", `/api/v1/plants/${plantId}/unmerge`));
|
|
289
|
-
server.tool("set_plant_status", "WRITE: set a plant's lifecycle status to LIVING, DEAD, or LOST. This is deliberately narrower than the full status range the app's edit form offers: GIFTED/SOLD/TRADED need a recipient and are out of scope for an API key (see docs), and MERGED/SPLIT/EXTERNAL each have their own dedicated tools (merge_plants/unmerge_plant, set_plant_reference) that keep their companion state consistent — setting status alone to one of those would corrupt it. deadDeclaredAt only applies when status is DEAD (defaults to the plant's existing value, then today). deathCause/deathNote apply to DEAD and LOST alike — a plant marked LOST is gone the same way a DEAD one is, and both feed the same after-the-fact retrospective the app builds from watering/repotting/photo history. Moving to LIVING clears deadDeclaredAt, deathCause, and deathNote. Requires a write-scoped API key.", {
|
|
290
|
-
plantId: z.number().int().positive().describe("Plant id."),
|
|
291
|
-
status: z.enum(["LIVING", "DEAD", "LOST"]).describe("New status."),
|
|
292
|
-
deadDeclaredAt: optionalPlantDate("Only applies when status is DEAD."),
|
|
293
|
-
deathCause: z
|
|
294
|
-
.enum(["overwatering", "underwatering", "pests", "disease", "rootRot", "light", "cold", "transplantShock", "oldAge", "unknown", "other"])
|
|
295
|
-
.optional()
|
|
296
|
-
.describe("Only applies when status is DEAD or LOST. A constrained pick, not free text — pass 'unknown' rather than omitting it when the cause genuinely isn't known but you want that on record."),
|
|
297
|
-
deathNote: z.string().max(300).optional().describe("Only applies when status is DEAD or LOST. Optional free-text detail alongside deathCause, e.g. 'moved outside for summer, forgot to bring in before the first frost'."),
|
|
298
|
-
}, async ({ plantId, ...body }) => apiSend("PATCH", `/api/v1/plants/${plantId}/status`, body));
|
|
299
|
-
server.tool("set_plant_reference", "WRITE: flip a plant between LIVING (the caller's own) and EXTERNAL (a reference plant — somebody else's, kept only as a lineage anchor). reference: true requires the plant to currently be LIVING; false requires it to currently be EXTERNAL. Reversible either direction; nothing on the plant is lost (photos, notes, care history, lineage all stay). Marking a plant as a reference also removes it from any vacation plan, since a reference plant is never cared for. create_plant's reference flag covers making a *new* reference plant; this is for converting one already in the collection. Requires a write-scoped API key.", {
|
|
300
|
-
plantId: z.number().int().positive().describe("Plant id."),
|
|
301
|
-
reference: z.boolean().describe("true: mark as a reference plant (must be LIVING now). false: turn a reference plant back into the caller's own (must be EXTERNAL now)."),
|
|
302
|
-
}, async ({ plantId, reference }) => apiSend("PATCH", `/api/v1/plants/${plantId}/reference`, { reference }));
|
|
303
|
-
server.tool("establish_propagation", "WRITE: promote a plant from PROPAGATING to ESTABLISHED, stamping establishedAt to now. This is the same one-way transition the app's plant edit form applies manually, and the action behind the 'is it still a cutting?' recommendation (propagationCheckDue, from list_care_recommendations) on the Improvements page. Fails with a 400 if the plant is not currently PROPAGATING. Requires a write-scoped API key.", {
|
|
304
|
-
plantId: z.number().int().positive().describe("The PROPAGATING plant to promote."),
|
|
305
|
-
}, async ({ plantId }) => apiSend("PATCH", `/api/v1/plants/${plantId}/establish`));
|
|
306
|
-
// --- Wishlist ----------------------------------------------------------
|
|
307
|
-
/**
|
|
308
|
-
* The caller's personal wishlist (R2-15) — plants they want but don't own
|
|
309
|
-
* yet, either a global catalog type (`plantTypeId`) or a free-text name
|
|
310
|
-
* fallback for a species not in the catalog. Unlike sales/trades, wishes
|
|
311
|
-
* are never exposed-but-excluded: they're the caller's own data, about
|
|
312
|
-
* their own collection, so there's no second-person consent concern.
|
|
313
|
-
*/
|
|
314
|
-
server.tool("list_wishlist", "List the caller's wishlist: each wish's plant type (when set) with its display/botanical name, or a free-text name fallback, plus notes and how many LIVING plants of that type the caller already owns.", async () => apiGet("/api/v1/wishlist"));
|
|
315
|
-
server.tool("add_to_wishlist", "WRITE: add a wish — a global catalog type (plantTypeId, from list_plant_types/search results) or a free-text name for a species not in the catalog yet. At least one is required. Never creates a second wish for a type the caller already has — returns the existing one instead (duplicate: true). Requires a write-scoped API key.", {
|
|
316
|
-
plantTypeId: z.number().int().positive().optional().describe("An existing global plant type id."),
|
|
317
|
-
name: z.string().max(100).optional().describe("Free-text name fallback, for a species not yet in the catalog."),
|
|
318
|
-
}, async ({ plantTypeId, name }) => apiSend("POST", "/api/v1/wishlist", { plantTypeId, name }));
|
|
319
|
-
server.tool("add_wishlist_note", "WRITE: add a dated note to a wish — price, where to get it, a shop link, etc. Requires a write-scoped API key.", {
|
|
320
|
-
wishId: z.number().int().positive().describe("Wish id, from list_wishlist."),
|
|
321
|
-
text: z.string().min(1).max(500).describe("The note text."),
|
|
322
|
-
}, async ({ wishId, text }) => apiSend("POST", `/api/v1/wishlist/${wishId}/notes`, { text }));
|
|
323
|
-
server.tool("remove_from_wishlist", "WRITE: remove a wish (e.g. once it's been fulfilled by a new plant). Requires a write-scoped API key.", {
|
|
324
|
-
wishId: z.number().int().positive().describe("Wish id, from list_wishlist."),
|
|
325
|
-
}, async ({ wishId }) => apiSend("DELETE", `/api/v1/wishlist/${wishId}`));
|
|
326
|
-
// --- Watched plant types -------------------------------------------------
|
|
327
|
-
/**
|
|
328
|
-
* The caller's "watch a type" subscriptions — the eye toggle on a catalog
|
|
329
|
-
* row or type page. A watched type notifies its watcher (in-app, via the
|
|
330
|
-
* Neuigkeiten centre — see list_notifications below) whenever an admin
|
|
331
|
-
* edits its fields, identity or names, verifies it, merges it into another
|
|
332
|
-
* type, or a new photo is approved for it.
|
|
333
|
-
*/
|
|
334
|
-
server.tool("list_watched_types", "List the caller's watched plant types: each watch's type id, display/botanical name and status.", async () => apiGet("/api/v1/plant-type-watches"));
|
|
335
|
-
server.tool("watch_type", "WRITE: watch a global catalog type (plantTypeId, from list_plant_types/search results) — the caller gets notified in-app when its fields, identity, names, verification status or photos change. Never creates a second watch for a type already watched — returns the existing one instead (duplicate: true). Requires a write-scoped API key.", {
|
|
336
|
-
plantTypeId: z.number().int().positive().describe("An existing global plant type id."),
|
|
337
|
-
}, async ({ plantTypeId }) => apiSend("POST", "/api/v1/plant-type-watches", { plantTypeId }));
|
|
338
|
-
server.tool("unwatch_type", "WRITE: stop watching a plant type. Requires a write-scoped API key.", {
|
|
339
|
-
plantTypeId: z.number().int().positive().describe("The watched type's id, from list_watched_types."),
|
|
340
|
-
}, async ({ plantTypeId }) => apiSend("DELETE", `/api/v1/plant-type-watches/${plantTypeId}`));
|
|
341
|
-
// --- Notifications -----------------------------------------------------------
|
|
342
|
-
/**
|
|
343
|
-
* The in-app notification centre (`/notifications`): catalog decisions on a
|
|
344
|
-
* watched type, a watched/created type's photo or proposal outcome, and a
|
|
345
|
-
* collection share — one durable, always-on channel alongside push/email.
|
|
346
|
-
* Sale/trade notifications are always excluded here, the same permanent
|
|
347
|
-
* exclusion `list_recent_activity` applies — each names a second person who
|
|
348
|
-
* never consented to the owner's API key.
|
|
349
|
-
*/
|
|
350
|
-
server.tool("list_notifications", "List the caller's notifications, newest first, cursor-paginated. Each item has a messageKey and values (interpolation data, not pre-rendered text — the exact sentence depends on locale) plus an href into the app and, when the event has one, the actor who triggered it. Sale/trade notifications are never included. Pass the previous call's nextCursor to load the next page.", {
|
|
351
|
-
cursor: z.number().int().optional().describe("Pass the previous call's nextCursor to continue; omit for the first page."),
|
|
352
|
-
}, async ({ cursor }) => apiGet("/api/v1/notifications", { cursor }));
|
|
353
|
-
server.tool("mark_notifications_read", "WRITE: mark every currently-unread notification read — the same as the notification centre's 'Alles als gelesen markieren' action. There is no per-notification mark-read tool; the app itself has none to mirror (opening the page already marks everything read). Requires a write-scoped API key.", async () => apiSend("POST", "/api/v1/notifications/mark-read"));
|
|
354
|
-
// --- Catalogs (for resolving filter ids) -----------------------------------
|
|
355
|
-
server.tool("list_locations", "List the user's locations.", async () => apiGet("/api/v1/locations"));
|
|
356
|
-
/**
|
|
357
|
-
* R2-12: the global plant type catalog, shared by every user — not a
|
|
358
|
-
* per-user list any more. `list_plant_types` and `search_plant_types` are
|
|
359
|
-
* deliberately the same underlying endpoint/reader (`GET /api/v1/plant-types`
|
|
360
|
-
* — see `docs/mcp-server.md`'s "Catalog reads" section): browsing and
|
|
361
|
-
* searching are the same fuzzy-filtered, paged list, just with `query`
|
|
362
|
-
* required for one and optional for the other, so a model reaching for
|
|
363
|
-
* either gets the same result shape and paging behavior.
|
|
364
|
-
*/
|
|
365
|
-
const publicPlantTypeListArgs = {
|
|
366
|
-
status: z.enum(["UNVERIFIED", "VERIFIED"]).optional().describe("Filter by review status. Defaults to every live type (both)."),
|
|
367
|
-
inMyCollection: z.boolean().optional().describe("true: only types the caller has at least one LIVING plant of."),
|
|
368
|
-
limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
|
|
369
|
-
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
370
|
-
};
|
|
371
|
-
server.tool("list_plant_types", "Browse the shared global plant type catalog — every plant's species/cultivar entry, one catalog for the whole app. Pass query to search by name/botanical; omit it to just browse (optionally filtered by status/inMyCollection). Compact rows only — no full field values or facts; use get_plant_type for one type's fact sheet. A MERGED type never appears here — see get_plant_type for how an id that was since merged resolves.", { query: z.string().optional().describe("Fuzzy search over names (all locales) and the botanical name."), ...publicPlantTypeListArgs }, async (args) => apiGet("/api/v1/plant-types", { ...args, inMyCollection: args.inMyCollection?.toString() }));
|
|
372
|
-
server.tool("search_plant_types", "Fuzzy search the shared global plant type catalog by name (all locales) or botanical name — the search-first counterpart to list_plant_types (same endpoint, query required here). Use this when the caller named a species/plant and you need its id, e.g. before create_plant or update_plant.", { query: z.string().min(1).describe("Search text — a common name in any language, or a botanical name."), ...publicPlantTypeListArgs }, async (args) => apiGet("/api/v1/plant-types", { ...args, inMyCollection: args.inMyCollection?.toString() }));
|
|
373
|
-
server.tool("get_plant_type", "Get one global plant type's fact sheet: identity and status, names by locale, parent/cultivar children, effective catalog facts (light, water, feeding need, toxicity, climate, growth, substrate/propagation, family/native region) with each field's source ('own' vs. 'inherited' from an ancestor), approved image URLs, and how many of the caller's own LIVING plants use it. A MERGED id transparently resolves to its survivor (mergedIntoId is still reported) rather than returning a redirect stub — unlike the admin equivalent.", { id: z.number().int().describe("Global plant type id, from list_plant_types/search_plant_types/get_plant.") }, async ({ id }) => apiGet(`/api/v1/plant-types/${id}`));
|
|
374
|
-
server.tool("create_plant_type", "WRITE: add a species/cultivar the shared global catalog is missing — the same capability as the plant-type picker's 'Neue Art anlegen' dialog. Always lands UNVERIFIED. At least one of genus or commonName is required. Only a small 'Steckbrief' subset of catalog fields is settable here (sunRequirement and waterRequirement are required within it; suggestedWateringDaysSummer/Winter, toxicToPets, difficulty are optional) — a fuller edit needs the UI or an admin key. Unlike the UI dialog (which warns about likely duplicates client-side before you even submit), this checks server-side: if a likely duplicate is found, nothing is created and the response is { created: false, duplicates } instead — review them (or resolve the plant onto an existing type with update_plant/create_plant) and either use one of those ids, or call again with confirmDespiteDuplicates: true to create anyway. Requires a write-scoped API key.", {
|
|
375
|
-
genus: z.string().max(100).optional().describe("Genus. Required together with species/cultivar identity, or provide commonName instead."),
|
|
376
|
-
species: z.string().max(100).optional().describe("Species epithet."),
|
|
377
|
-
cultivar: z.string().max(100).optional().describe("Cultivar name."),
|
|
378
|
-
commonName: z.string().max(100).optional().describe("A common name, in the caller's own locale. Required if genus is omitted."),
|
|
379
|
-
values: z
|
|
380
|
-
.object({
|
|
381
|
-
sunRequirement: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
382
|
-
waterRequirement: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
383
|
-
suggestedWateringDaysSummer: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
384
|
-
suggestedWateringDaysWinter: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
385
|
-
toxicToPets: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
386
|
-
difficulty: z.union([z.number(), z.string(), z.boolean(), z.array(z.string())]).nullable().optional(),
|
|
387
|
-
})
|
|
388
|
-
.describe("The 'Steckbrief' fields — sunRequirement and waterRequirement are required (use admin_get_plant_type_field_schema's field definitions for exact value shapes/bounds); the rest are optional."),
|
|
389
|
-
note: z.string().max(500).optional().describe("Optional context for the catalog change log. Defaults to a generic 'created via MCP' note."),
|
|
390
|
-
confirmDespiteDuplicates: z.boolean().optional().describe("Set true to create anyway after reviewing the duplicates a prior call returned."),
|
|
391
|
-
}, async (body) => apiSend("POST", "/api/v1/plant-types", body));
|
|
392
|
-
server.tool("create_location", "WRITE: create a location — the same 'create inline' picker on the plant form. Never conflicts with an existing owned catalog entry: a duplicate name is rejected by the underlying unique constraint, surfaced as an error. Requires a write-scoped API key.", {
|
|
393
|
-
name: z.string().min(1).max(100).describe("Location name."),
|
|
394
|
-
parentId: z.number().int().positive().optional().describe("An owned location id to nest this one under (from list_locations). Omit for a top-level location."),
|
|
395
|
-
controlledClimate: z.boolean().optional().describe("True for a place with constant light and warmth all year (e.g. a plant cabinet with a grow light): plants placed directly here follow their summer watering and fertilizing cycle year-round, unless the plant opts back into winter care. Omit for false."),
|
|
396
|
-
}, async ({ name, parentId, controlledClimate }) => apiSend("POST", "/api/v1/locations", { name, parentId, controlledClimate }));
|
|
397
|
-
server.tool("update_location", "WRITE: rename, move or change the climate setting of an owned location. Only the fields you pass change. Requires a write-scoped API key.", {
|
|
398
|
-
locationId: z.number().int().positive().describe("The location id (from list_locations)."),
|
|
399
|
-
name: z.string().min(1).max(100).optional().describe("New name."),
|
|
400
|
-
parentId: z.number().int().positive().nullable().optional().describe("An owned location id to nest this one under; null moves it to the top level. Omit to keep the current parent. Cycles are rejected."),
|
|
401
|
-
controlledClimate: z.boolean().optional().describe("True: plants placed directly in this location (not its sublocations) follow their summer watering and fertilizing cycle year-round, unless the plant opts back into winter care. False: normal seasonal cycle again. Omit to keep the current setting."),
|
|
402
|
-
}, async ({ locationId, name, parentId, controlledClimate }) => apiSend("PATCH", `/api/v1/locations/${locationId}`, { name, parentId, controlledClimate }));
|
|
403
|
-
server.tool("list_soils", "List the user's soils.", async () => apiGet("/api/v1/soils"));
|
|
404
|
-
server.tool("create_soil", "WRITE: create a soil mix, optionally with components (name + parts) — mirrors the app's soil mix editor. Each component is matched or created by name under the caller's account. Requires a write-scoped API key.", {
|
|
405
|
-
name: z.string().min(1).max(100).describe("Soil name."),
|
|
406
|
-
notes: z.string().max(2000).optional().describe("Free-text notes."),
|
|
407
|
-
items: z
|
|
408
|
-
.array(z.object({ componentName: z.string().min(1).max(100), parts: z.number().int().min(1).max(100) }))
|
|
409
|
-
.optional()
|
|
410
|
-
.describe("Mix components as whole-number parts, e.g. [{ componentName: 'Kokoserde', parts: 3 }, { componentName: 'Perlite', parts: 1 }]. Omit for a soil with no recorded mix."),
|
|
411
|
-
}, async ({ name, notes, items }) => apiSend("POST", "/api/v1/soils", { name, notes, items }));
|
|
412
|
-
server.tool("merge_soils", "WRITE: merge one soil into another — for duplicate soil mixes (e.g. names differing only by a stray space). Every plant and potting-event record on sourceId moves to targetId, then sourceId is deleted. Unlike deleting a soil directly, this never refuses for living plants still using it — moving them is the point. sourceId's own mix recipe (components/parts) is dropped; targetId keeps its own. Irreversible — there is no unmerge. Requires a write-scoped API key.", {
|
|
413
|
-
sourceId: z.number().int().positive().describe("The soil to merge away and delete. From list_soils."),
|
|
414
|
-
targetId: z.number().int().positive().describe("The soil that survives and absorbs sourceId's plants and potting events. From list_soils."),
|
|
415
|
-
}, async ({ sourceId, targetId }) => apiSend("POST", "/api/v1/soils/merge", { sourceId, targetId }));
|
|
416
|
-
server.tool("list_fertilizers", "List the user's fertilizers.", async () => apiGet("/api/v1/fertilizers"));
|
|
417
|
-
server.tool("create_fertilizer", "WRITE: create a fertilizer, with an optional NPK label and base dose (ml per liter of water) used to default record_fertilization's fertilizerPercent. Requires a write-scoped API key.", {
|
|
418
|
-
name: z.string().min(1).max(100).describe("Fertilizer name."),
|
|
419
|
-
npk: z.string().max(30).optional().describe("NPK ratio label, e.g. '7-3-6'."),
|
|
420
|
-
baseDosePerLiterMl: z.number().min(0).max(100).optional().describe("Base dose in ml per liter of water."),
|
|
421
|
-
}, async ({ name, npk, baseDosePerLiterMl }) => apiSend("POST", "/api/v1/fertilizers", { name, npk, baseDosePerLiterMl }));
|
|
422
|
-
server.tool("list_pots", "List the caller's own pots plus the shared default pots, defaults first. Each includes its reference links and any recorded sizes (addressed by id from record_repotting-style potSize fields once that tool exists).", async () => apiGet("/api/v1/pots"));
|
|
423
|
-
server.tool("create_pot", "WRITE: create a pot, optionally with reference links (a shop or care-guide URL). Pot sizes have no create tool yet — add one from the app after creating the pot. Requires a write-scoped API key.", {
|
|
424
|
-
name: z.string().min(1).max(100).describe("Pot name."),
|
|
425
|
-
material: z.string().max(50).optional().describe("Material, e.g. 'Terracotta'."),
|
|
426
|
-
isSelfWatering: z.boolean().optional().describe("Whether the pot is self-watering."),
|
|
427
|
-
notes: z.string().max(2000).optional().describe("Free-text notes."),
|
|
428
|
-
links: z
|
|
429
|
-
.array(z.object({ url: z.string().max(2000), label: z.string().max(80).optional() }))
|
|
430
|
-
.optional()
|
|
431
|
-
.describe("Reference links, e.g. a shop listing. Order follows the array."),
|
|
432
|
-
}, async ({ name, material, isSelfWatering, notes, links }) => apiSend("POST", "/api/v1/pots", { name, material, isSelfWatering, notes, links }));
|
|
433
|
-
// --- Admin: global plant type catalog (admin-scoped keys only) -------------
|
|
434
|
-
/**
|
|
435
|
-
* Tools over the shared, community-maintained plant type catalog — finding
|
|
436
|
-
* and understanding duplicates, restructuring the genus/species/cultivar
|
|
437
|
-
* tree, creating and editing catalog entries directly, and (since this
|
|
438
|
-
* increment) merging two types, reviewing a proposal, and dismissing or
|
|
439
|
-
* rescanning duplicate candidates. Every description starts with `ADMIN:`
|
|
440
|
-
* for a read or `ADMIN WRITE:` for a write; `check-tool-docs.mjs` classifies
|
|
441
|
-
* a tool by that prefix (`WRITE:` for an ordinary write tool, `ADMIN WRITE:`
|
|
442
|
-
* for an admin write, `ADMIN:` for an admin read, anything else an ordinary
|
|
443
|
-
* read) — see the comment at the top of that script.
|
|
444
|
-
*
|
|
445
|
-
* These are registered conditionally — see `registerAdminToolsIfPermitted`
|
|
446
|
-
* below — so a non-admin key's session never sees a tool it can't call.
|
|
447
|
-
* Every `ADMIN WRITE:` tool requires a non-empty `note`, recorded on the
|
|
448
|
-
* catalog's change log so an assistant's edit is always traceable, with two
|
|
449
|
-
* exceptions: `admin_dismiss_duplicate_candidate` requires one for
|
|
450
|
-
* consistency but has nowhere to store it (see its own description), and
|
|
451
|
-
* `admin_rescan_duplicate_candidates` needs none at all (no decision is
|
|
452
|
-
* being made). The same `admin` scope covers both the reads and the writes
|
|
453
|
-
* here (not also `write`; the three API-key scopes are independent grants,
|
|
454
|
-
* see docs/mcp-server.md's Scopes section).
|
|
455
|
-
*/
|
|
456
|
-
function registerAdminTools() {
|
|
457
|
-
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.", {
|
|
458
|
-
status: z.enum(["UNVERIFIED", "VERIFIED", "MERGED"]).optional().describe("Filter by review status. Defaults to every non-MERGED type."),
|
|
459
|
-
search: z.string().optional().describe("Fuzzy search over names (all locales) and the botanical name."),
|
|
460
|
-
genus: z.string().optional().describe("Filter to an exact genus (case-insensitive)."),
|
|
461
|
-
parentId: z.number().int().optional().describe("Filter to the cultivar children of this species-level type id."),
|
|
462
|
-
missingField: z
|
|
463
|
-
.string()
|
|
464
|
-
.optional()
|
|
465
|
-
.describe("A catalog field key (from admin_get_plant_type_field_schema) — only types with no own value for it."),
|
|
466
|
-
hasOpenProposals: z.boolean().optional().describe("true: only types with a pending proposal awaiting review."),
|
|
467
|
-
limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
|
|
468
|
-
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
469
|
-
}, async (args) => apiGet("/api/v1/admin/plant-types", { ...args, hasOpenProposals: args.hasOpenProposals?.toString() }));
|
|
470
|
-
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}`));
|
|
471
|
-
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"));
|
|
472
|
-
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.", {
|
|
473
|
-
status: z.enum(["OPEN", "DISMISSED", "MERGED"]).optional().describe("Defaults to OPEN."),
|
|
474
|
-
limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
|
|
475
|
-
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
476
|
-
}, async (args) => apiGet("/api/v1/admin/plant-types/duplicates", args));
|
|
477
|
-
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.", {
|
|
478
|
-
status: z.enum(["PENDING", "ACCEPTED", "PARTIAL", "DENIED", "WITHDRAWN"]).optional().describe("Defaults to PENDING."),
|
|
479
|
-
origin: z.enum(["user", "migration"]).optional().describe("Filter by who filed the proposal."),
|
|
480
|
-
typeId: z.number().int().optional().describe("Only proposals for this plant type id."),
|
|
481
|
-
limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
|
|
482
|
-
offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
|
|
483
|
-
}, async (args) => apiGet("/api/v1/admin/plant-types/proposals", args));
|
|
484
|
-
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.", {
|
|
485
|
-
sourceId: z.number().int().describe("The type that would be merged away."),
|
|
486
|
-
targetId: z.number().int().describe("The type that would survive."),
|
|
487
|
-
}, async ({ sourceId, targetId }) => apiGet("/api/v1/admin/plant-types/merge-preview", { sourceId, targetId }));
|
|
488
|
-
const catalogFieldValues = z
|
|
489
|
-
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string()), z.null()]))
|
|
490
|
-
.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.");
|
|
491
|
-
const catalogNameEntry = z.object({
|
|
492
|
-
name: z.string().min(1).describe("The name text."),
|
|
493
|
-
locale: z.enum(["de", "en"]).nullable().describe("'de', 'en', or null for any other language."),
|
|
494
|
-
isPrimary: z.boolean().describe("Whether this is the display name shown for its locale."),
|
|
495
|
-
});
|
|
496
|
-
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.", {
|
|
497
|
-
genus: z.string().min(1).describe("Genus, required."),
|
|
498
|
-
species: z.string().nullable().optional().describe("Species epithet. Omit or null for a genus-only/unidentified-species type."),
|
|
499
|
-
cultivar: z.string().nullable().optional().describe("Cultivar name. Setting this makes the new type a cultivar attached under the matching species-level type."),
|
|
500
|
-
names: z.array(catalogNameEntry).optional().describe("Initial full name list. Defaults to none."),
|
|
501
|
-
values: catalogFieldValues.optional().describe("Optional initial catalog field values."),
|
|
502
|
-
status: z.enum(["UNVERIFIED", "VERIFIED"]).optional().describe("Review status. Defaults to VERIFIED."),
|
|
503
|
-
note: z.string().min(1).describe("Required note, recorded on the new type's change log."),
|
|
504
|
-
}, async ({ names, values, status, note, ...identity }) => apiSend("POST", "/api/v1/admin/plant-types", { ...identity, names, values, status, note }));
|
|
505
|
-
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.", {
|
|
506
|
-
id: z.number().int().describe("Global plant type id."),
|
|
507
|
-
values: catalogFieldValues,
|
|
508
|
-
note: z.string().min(1).describe("Required note, recorded on the change log."),
|
|
509
|
-
}, async ({ id, values, note }) => apiSend("PATCH", `/api/v1/admin/plant-types/${id}/fields`, { values, note }));
|
|
510
|
-
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.", {
|
|
511
|
-
id: z.number().int().describe("Global plant type id."),
|
|
512
|
-
names: z.array(catalogNameEntry).describe("The complete replacement name list."),
|
|
513
|
-
note: z.string().min(1).describe("Required note, recorded on the change log."),
|
|
514
|
-
}, async ({ id, names, note }) => apiSend("PUT", `/api/v1/admin/plant-types/${id}/names`, { names, note }));
|
|
515
|
-
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.", {
|
|
516
|
-
id: z.number().int().describe("Global plant type id."),
|
|
517
|
-
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)."),
|
|
518
|
-
species: z.string().nullable().describe("New species epithet, or null."),
|
|
519
|
-
cultivar: z.string().nullable().describe("New cultivar name, or null."),
|
|
520
|
-
note: z.string().min(1).describe("Required note, recorded on the change log."),
|
|
521
|
-
}, async ({ id, genus, species, cultivar, note }) => apiSend("PATCH", `/api/v1/admin/plant-types/${id}/identity`, { genus, species, cultivar, note }));
|
|
522
|
-
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.", {
|
|
523
|
-
id: z.number().int().describe("Global plant type id."),
|
|
524
|
-
note: z.string().min(1).describe("Required note, recorded on the change log."),
|
|
525
|
-
}, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/${id}/verify`, { note }));
|
|
526
|
-
const catalogFieldChoices = z
|
|
527
|
-
.record(z.string(), z.enum(["source", "target"]))
|
|
528
|
-
.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.");
|
|
529
|
-
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.", {
|
|
530
|
-
sourceId: z.number().int().describe("The type that will be merged away (becomes a MERGED redirect)."),
|
|
531
|
-
targetId: z.number().int().describe("The type that will survive and receive everything from sourceId."),
|
|
532
|
-
note: z.string().min(1).describe("Required note, recorded on the change log of both types."),
|
|
533
|
-
fieldChoices: catalogFieldChoices.optional(),
|
|
534
|
-
}, async ({ sourceId, targetId, note, fieldChoices }) => apiSend("POST", "/api/v1/admin/plant-types/merge", { sourceId, targetId, note, fieldChoices }));
|
|
535
|
-
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.", {
|
|
536
|
-
id: z.number().int().describe("Proposal id, from admin_list_plant_type_proposals."),
|
|
537
|
-
fields: z
|
|
538
|
-
.array(z.string())
|
|
539
|
-
.optional()
|
|
540
|
-
.describe("Subset of the proposal's changed fields to accept. Omit to accept all of them."),
|
|
541
|
-
note: z.string().min(1).describe("Required note, recorded on the change log."),
|
|
542
|
-
}, async ({ id, fields, note }) => apiSend("POST", `/api/v1/admin/plant-types/proposals/${id}/accept`, { fields, note }));
|
|
543
|
-
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.", {
|
|
544
|
-
id: z.number().int().describe("Proposal id, from admin_list_plant_type_proposals."),
|
|
545
|
-
note: z.string().min(1).describe("Required note (recorded on the proposal, not the type's change log)."),
|
|
546
|
-
}, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/proposals/${id}/deny`, { note }));
|
|
547
|
-
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.", {
|
|
548
|
-
typeId: z.number().int().describe("Global plant type id."),
|
|
549
|
-
origin: z.enum(["user", "migration"]).optional().describe("Only deny proposals filed by this origin. Omit to deny every PENDING proposal regardless of origin."),
|
|
550
|
-
note: z.string().min(1).describe("Required note, applied to every denial."),
|
|
551
|
-
}, async ({ typeId, origin, note }) => apiSend("POST", "/api/v1/admin/plant-types/proposals/deny", { typeId, origin, note }));
|
|
552
|
-
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.", {
|
|
553
|
-
id: z.number().int().describe("Duplicate candidate id, from admin_list_duplicate_candidates."),
|
|
554
|
-
note: z.string().min(1).describe("Required, but not persisted — see the tool description."),
|
|
555
|
-
}, async ({ id, note }) => apiSend("POST", `/api/v1/admin/plant-types/duplicates/${id}/dismiss`, { note }));
|
|
556
|
-
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", {}));
|
|
557
|
-
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"));
|
|
558
|
-
}
|
|
559
|
-
/**
|
|
560
|
-
* Calls `/api/v1/me` once at startup to decide whether this key carries the
|
|
561
|
-
* `admin` scope, and registers the admin catalog tools only when it does —
|
|
562
|
-
* so a non-admin key's session never even sees a tool it could not call
|
|
563
|
-
* (rather than registering it and letting every call fail with 403). A
|
|
564
|
-
* failed whoami call (network error, invalid key — `apiGet`'s own retry-free
|
|
565
|
-
* failure modes) is logged to stderr and registers no admin tools, the same
|
|
566
|
-
* as a confirmed non-admin key: better to under-register than to guess.
|
|
567
|
-
*/
|
|
568
|
-
async function registerAdminToolsIfPermitted() {
|
|
569
|
-
let res;
|
|
570
|
-
try {
|
|
571
|
-
res = await fetch(new URL(`${API_URL}/api/v1/me`), {
|
|
572
|
-
headers: { Authorization: `Bearer ${API_KEY}`, Accept: "application/json" },
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
catch (err) {
|
|
576
|
-
console.error(`[plants-mcp] Could not reach ${API_URL}/api/v1/me to check for admin access: ${String(err)}. Admin tools not registered.`);
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
if (!res.ok) {
|
|
580
|
-
console.error(`[plants-mcp] /api/v1/me returned HTTP ${res.status}; admin tools not registered.`);
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
let body;
|
|
584
|
-
try {
|
|
585
|
-
body = await res.json();
|
|
586
|
-
}
|
|
587
|
-
catch {
|
|
588
|
-
console.error("[plants-mcp] /api/v1/me returned an unreadable response; admin tools not registered.");
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
const scopes = body?.scopes;
|
|
592
|
-
if (Array.isArray(scopes) && scopes.includes("admin")) {
|
|
593
|
-
registerAdminTools();
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
// --- Boot -------------------------------------------------------------------
|
|
597
|
-
async function main() {
|
|
598
|
-
await registerAdminToolsIfPermitted();
|
|
599
|
-
const transport = new StdioServerTransport();
|
|
600
|
-
await server.connect(transport);
|
|
601
|
-
console.error(`[plants-mcp] v${VERSION} connected. API base: ${API_URL}`);
|
|
602
|
-
}
|
|
603
|
-
main().catch((err) => {
|
|
604
|
-
console.error("[plants-mcp] fatal:", err);
|
|
605
|
-
process.exit(1);
|
|
606
|
-
});
|
|
2
|
+
import "./mcp/src/index.js";
|