@sebamomann/plants-mcp 0.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.
Files changed (3) hide show
  1. package/README.md +108 -0
  2. package/dist/index.js +261 -0
  3. package/package.json +50 -0
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # plants-mcp
2
+
3
+ [MCP](https://modelcontextprotocol.io) server for the **Sprig** plant app. It lets a Claude agent query
4
+ your plant collection — plants, watering/fertilization events, the full care timeline, photos, health
5
+ entries, the care schedule, and lookup catalogs — and, with a write-scoped key, record waterings,
6
+ fertilizings, and health notes. It talks to the app's `/api/v1/*` REST API, authenticated with a
7
+ **per-user API key**.
8
+
9
+ Reads need only the `read` scope (every key has it). The write tools — `record_watering`,
10
+ `record_fertilization`, `add_health_entry` — need a key with the `write` scope, chosen when you create
11
+ the key. There are no destructive tools: nothing deletes plants, events, or history.
12
+
13
+ ## Setup
14
+
15
+ 1. **Generate an API key** in the app: **Account → API Keys → Generate**. Pick **Read-only** or
16
+ **Read & write** (write is needed for the logging tools). Copy it (it's shown once).
17
+ 2. **Install.** Published on npm, so no clone is needed — `npx` fetches it on demand:
18
+ ```sh
19
+ npx @sebamomann/plants-mcp
20
+ ```
21
+ To work on it locally instead, clone the repo and build:
22
+ ```sh
23
+ cd plants-mcp
24
+ npm install
25
+ npm run build
26
+ ```
27
+ 3. **Configure** via environment variables (see `.env.example`):
28
+ - `PLANT_API_URL` — base URL of the running app (`http://localhost:3000` or your deployed site).
29
+ - `PLANT_API_KEY` — the `sprig_live_…` key from step 1.
30
+
31
+ ## Register with Claude
32
+
33
+ Add to your MCP client config (e.g. Claude Code / Claude Desktop). Example:
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "plants": {
39
+ "command": "npx",
40
+ "args": ["-y", "@sebamomann/plants-mcp"],
41
+ "env": {
42
+ "PLANT_API_URL": "http://localhost:3000",
43
+ "PLANT_API_KEY": "sprig_live_..."
44
+ }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ To run a local checkout instead, point at the build output —
51
+ `"command": "node", "args": ["/…/plants-mcp/dist/index.js"]` — or skip the build and use `tsx`:
52
+ `"command": "npx", "args": ["tsx", "/…/plants-mcp/src/index.ts"]`.
53
+
54
+ ## Tools
55
+
56
+ | Tool | Endpoint | Purpose |
57
+ |---|---|---|
58
+ | `whoami` | `GET /api/v1/me` | Identify the authenticated user |
59
+ | `list_plants` | `GET /api/v1/plants` | List plants with filters (status, lifecycle, location, type, soil, fertilizer, search, pagination, sort) |
60
+ | `get_plant` | `GET /api/v1/plants/:id` | Full detail for one plant |
61
+ | `list_watering_events` | `GET /api/v1/plants/:id/watering-events` | Watering history |
62
+ | `list_fertilization_events` | `GET /api/v1/plants/:id/fertilization-events` | Fertilization history |
63
+ | `list_care_events` | `GET /api/v1/plants/:id/events` | Combined care timeline (typed by `kind`) |
64
+ | `list_photos` | `GET /api/v1/plants/:id/photos` | Photo metadata |
65
+ | `list_health_entries` | `GET /api/v1/plants/:id/health` | Health / AI-analysis entries |
66
+ | `list_due_care` | `GET /api/v1/care/due` | What needs water/fertilizer now — overdue plus the given day, optional look-ahead window |
67
+ | `list_overdue_care` | `GET /api/v1/care/overdue` | Only past-due work, most overdue first, with days late |
68
+ | `get_care_calendar` | `GET /api/v1/care/calendar` | Day-by-day schedule over a range, plus an overdue group |
69
+ | `list_care_recommendations` | `GET /api/v1/care/recommendations` | Detected care problems (cycle mismatch, chronic lateness, seasonal gaps, stale photos) |
70
+ | `list_recent_activity` | `GET /api/v1/activity` | Collection-wide care activity over a date range, typed by `kind` — use instead of looping the per-plant event tools |
71
+ | `list_locations` | `GET /api/v1/locations` | Location catalog |
72
+ | `list_plant_types` | `GET /api/v1/plant-types` | Plant-type (species) catalog |
73
+ | `list_soils` | `GET /api/v1/soils` | Soil catalog |
74
+ | `list_fertilizers` | `GET /api/v1/fertilizers` | Fertilizer catalog |
75
+
76
+ ### Write tools (require the `write` scope)
77
+
78
+ | Tool | Endpoint | Purpose |
79
+ |---|---|---|
80
+ | `record_watering` | `POST /api/v1/care/watering` | Log a watering for one or more plants (bulk); reservoir/hydro routed to refill/top-up |
81
+ | `record_fertilization` | `POST /api/v1/care/fertilization` | Log a fertilization for one or more plants (bulk) |
82
+ | `add_health_entry` | `POST /api/v1/plants/:id/health` | Add a manual observation or issue note to a plant |
83
+ | `update_health_entry` | `PATCH /api/v1/health-entries/:id` | Edit an existing entry's kind, text, category, or severity |
84
+ | `resolve_health_entry` | `PATCH /api/v1/health-entries/:id` | Mark an entry resolved, or reopen it |
85
+ | `dismiss_care_recommendation` | `POST /api/v1/care/recommendations/dismiss` | Hide one recommendation occurrence, by `plantId` + `type` + `fingerprint` |
86
+
87
+ Writes are **idempotent per day**: a plant already watered/fertilized on the target day is skipped and
88
+ returned in a `skipped` list rather than logged twice, so a retried call never shifts the care schedule.
89
+ Unknown plant ids come back in an `invalid` list. A read-only key calling a write tool gets HTTP 403.
90
+
91
+ There are **no destructive tools**. Nothing here deletes a plant, an event, a photo, or a catalog entry;
92
+ resolving and dismissing are both reversible, and retiring a plant is a status change. Deletion stays in
93
+ the UI on purpose.
94
+
95
+ The collection's **sales and trades are deliberately not exposed** — not as reads, not as writes. They
96
+ involve a second user and are out of scope for an assistant acting on the owner's key.
97
+
98
+ All results are scoped to the key's owner; another user's data returns 404 and a bad/missing/revoked key
99
+ returns 401.
100
+
101
+ The `care/*` tools return schedule state the server derives from the same helpers the app's own
102
+ calendar uses — season, care mode, and snoozes are already applied. Prefer them over recomputing due
103
+ dates from raw event lists. They cover `LIVING` plants only.
104
+
105
+ ## Security
106
+
107
+ - The key is sent only to `PLANT_API_URL` as a bearer token. Keep it secret; treat it like a password.
108
+ - Revoke a key any time from **Account → API Keys**; requests with it immediately start returning 401.
package/dist/index.js ADDED
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ /**
6
+ * Read-only MCP server for the Sprig plant app.
7
+ *
8
+ * Every tool is a thin proxy over the app's `/api/v1/*` REST endpoints,
9
+ * authenticated with a per-user API key sent as `Authorization: Bearer <key>`.
10
+ * Tools forward their arguments as query params and return the API's JSON
11
+ * verbatim, so they stay correct even if the API response shape evolves.
12
+ */
13
+ const API_URL = (process.env.PLANT_API_URL ?? "http://localhost:3000").replace(/\/+$/, "");
14
+ const API_KEY = process.env.PLANT_API_KEY;
15
+ if (!API_KEY) {
16
+ console.error("[plants-mcp] Missing PLANT_API_KEY environment variable.");
17
+ process.exit(1);
18
+ }
19
+ async function apiGet(path, query = {}) {
20
+ const url = new URL(`${API_URL}${path}`);
21
+ for (const [key, value] of Object.entries(query)) {
22
+ if (value !== undefined && value !== "")
23
+ url.searchParams.set(key, String(value));
24
+ }
25
+ let res;
26
+ try {
27
+ res = await fetch(url, {
28
+ headers: {
29
+ Authorization: `Bearer ${API_KEY}`,
30
+ Accept: "application/json",
31
+ },
32
+ });
33
+ }
34
+ catch (err) {
35
+ return {
36
+ content: [{ type: "text", text: `Network error calling ${url.pathname}: ${String(err)}` }],
37
+ isError: true,
38
+ };
39
+ }
40
+ const body = await res.text();
41
+ if (!res.ok) {
42
+ return {
43
+ content: [
44
+ {
45
+ type: "text",
46
+ text: `Request to ${url.pathname} failed (HTTP ${res.status}): ${body || res.statusText}`,
47
+ },
48
+ ],
49
+ isError: true,
50
+ };
51
+ }
52
+ // Pretty-print JSON when possible, otherwise return raw text.
53
+ let text = body;
54
+ try {
55
+ text = JSON.stringify(JSON.parse(body), null, 2);
56
+ }
57
+ catch {
58
+ // leave as-is
59
+ }
60
+ return { content: [{ type: "text", text }] };
61
+ }
62
+ /**
63
+ * Sends a JSON body to a mutating endpoint. Requires an API key with the
64
+ * `write` scope — a read-only key gets a 403 surfaced back to the model verbatim
65
+ * so it can tell the user their key can't write.
66
+ */
67
+ async function apiSend(method, path, body) {
68
+ const url = new URL(`${API_URL}${path}`);
69
+ let res;
70
+ try {
71
+ res = await fetch(url, {
72
+ method,
73
+ headers: {
74
+ Authorization: `Bearer ${API_KEY}`,
75
+ Accept: "application/json",
76
+ "Content-Type": "application/json",
77
+ },
78
+ body: JSON.stringify(body),
79
+ });
80
+ }
81
+ catch (err) {
82
+ return {
83
+ content: [{ type: "text", text: `Network error calling ${url.pathname}: ${String(err)}` }],
84
+ isError: true,
85
+ };
86
+ }
87
+ const text = await res.text();
88
+ if (!res.ok) {
89
+ return {
90
+ content: [
91
+ {
92
+ type: "text",
93
+ text: `Request to ${url.pathname} failed (HTTP ${res.status}): ${text || res.statusText}`,
94
+ },
95
+ ],
96
+ isError: true,
97
+ };
98
+ }
99
+ let pretty = text;
100
+ try {
101
+ pretty = JSON.stringify(JSON.parse(text), null, 2);
102
+ }
103
+ catch {
104
+ // leave as-is
105
+ }
106
+ return { content: [{ type: "text", text: pretty }] };
107
+ }
108
+ const server = new McpServer({ name: "plants-mcp", version: "0.1.0" });
109
+ // --- Identity ---------------------------------------------------------------
110
+ server.tool("whoami", "Return the authenticated user for the configured API key.", async () => apiGet("/api/v1/me"));
111
+ // --- Plants -----------------------------------------------------------------
112
+ server.tool("list_plants", "List the user's plants, with optional filters. Returns plant summaries.", {
113
+ status: z
114
+ .enum(["LIVING", "DEAD", "GIFTED", "LOST", "SOLD", "TRADED", "MERGED"])
115
+ .optional()
116
+ .describe("Filter by plant status."),
117
+ lifecycle: z.enum(["PROPAGATING", "ESTABLISHED"]).optional().describe("Filter by lifecycle stage."),
118
+ locationId: z.number().int().optional().describe("Filter by location id."),
119
+ plantTypeId: z.number().int().optional().describe("Filter by plant type (species) id."),
120
+ soilId: z.number().int().optional().describe("Filter by soil id."),
121
+ fertilizerId: z.number().int().optional().describe("Filter by fertilizer id."),
122
+ search: z.string().optional().describe("Free-text search over plant type name / notes."),
123
+ limit: z.number().int().min(1).max(200).optional().describe("Max results (pagination)."),
124
+ offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
125
+ sort: z.string().optional().describe("Sort key, e.g. 'name', 'createdAt', 'updatedAt'."),
126
+ }, async (args) => apiGet("/api/v1/plants", args));
127
+ server.tool("get_plant", "Get one plant with full detail: catalogs (type, location, soil, fertilizer), care config, and recent event summaries.", { id: z.number().int().describe("Plant id.") }, async ({ id }) => apiGet(`/api/v1/plants/${id}`));
128
+ // --- Events connected to a plant -------------------------------------------
129
+ const eventArgs = {
130
+ id: z.number().int().describe("Plant id."),
131
+ limit: z.number().int().min(1).max(500).optional().describe("Max results (pagination)."),
132
+ offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
133
+ };
134
+ 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));
135
+ 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));
136
+ 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));
137
+ 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));
138
+ 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));
139
+ // --- Care schedule (calendar) ----------------------------------------------
140
+ /**
141
+ * Derived schedule state, computed server-side from the same helpers the app's
142
+ * own calendar uses. Prefer these over reconstructing due dates from raw event
143
+ * lists: they account for season, care mode, and snoozes.
144
+ */
145
+ const careArgs = {
146
+ date: z
147
+ .string()
148
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
149
+ .optional()
150
+ .describe("Day to evaluate against, as YYYY-MM-DD. Defaults to today."),
151
+ season: z
152
+ .enum(["summer", "winter"])
153
+ .optional()
154
+ .describe("Override the season (Apr–Sep is summer). Defaults to the season of `date`."),
155
+ locationId: z.number().int().optional().describe("Only consider plants in this location."),
156
+ };
157
+ 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?'.", {
158
+ ...careArgs,
159
+ windowDays: z
160
+ .number()
161
+ .int()
162
+ .min(0)
163
+ .max(60)
164
+ .optional()
165
+ .describe("Also include work due within this many days after `date`. 0 (default) = that day only."),
166
+ includeOverdue: z
167
+ .boolean()
168
+ .optional()
169
+ .describe("Include already-overdue work (default true)."),
170
+ }, async (args) => apiGet("/api/v1/care/due", { ...args, includeOverdue: args.includeOverdue?.toString() }));
171
+ 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?'.", careArgs, async (args) => apiGet("/api/v1/care/overdue", args));
172
+ server.tool("get_care_calendar", "Day-by-day care schedule over a date range, plus an overdue group. Each plant appears on its next due day only — this projects the current schedule rather than simulating future waterings.", {
173
+ ...careArgs,
174
+ days: z.number().int().min(1).max(60).optional().describe("Number of days to project (default 14, max 60)."),
175
+ }, async (args) => apiGet("/api/v1/care/calendar", args));
176
+ server.tool("list_care_recommendations", "Care problems detected across the collection: watering-cycle mismatches, chronic lateness, missed seasonal fertilizing, recent repotting, stale photos. Returns i18n message keys plus their values, not rendered text. Dismissed recommendations are excluded.", async () => apiGet("/api/v1/care/recommendations"));
177
+ // --- Collection-wide activity ----------------------------------------------
178
+ 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.", {
179
+ since: z.string().optional().describe("Only events at or after this ISO date/timestamp, e.g. '2026-07-01'."),
180
+ until: z.string().optional().describe("Only events at or before this ISO date/timestamp."),
181
+ kinds: z
182
+ .array(z.enum(["watering", "fertilization", "refill", "hydro", "potting", "health", "photo"]))
183
+ .optional()
184
+ .describe("Restrict to these event kinds. Defaults to all of them."),
185
+ limit: z.number().int().min(1).max(500).optional().describe("Max results (pagination)."),
186
+ offset: z.number().int().min(0).optional().describe("Result offset (pagination)."),
187
+ }, async ({ kinds, ...rest }) => apiGet("/api/v1/activity", { ...rest, kinds: kinds?.join(",") }));
188
+ // --- Care logging (writes) --------------------------------------------------
189
+ /**
190
+ * Mutating tools. These require an API key with the `write` scope; a read-only
191
+ * key returns HTTP 403. They are idempotent per day — a plant already
192
+ * watered/fertilized on the target day is skipped, not logged twice — and
193
+ * accept many plants at once, so prefer one bulk call over a loop.
194
+ */
195
+ const optionalPlantDate = (label) => z
196
+ .string()
197
+ .regex(/^\d{4}-\d{2}-\d{2}/)
198
+ .optional()
199
+ .describe(`When it happened, ISO date (YYYY-MM-DD). Defaults to now. Cannot be in the future. ${label}`);
200
+ 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.", {
201
+ plantIds: z.array(z.number().int().positive()).min(1).max(200).describe("Plant ids to water."),
202
+ wateredAt: optionalPlantDate(""),
203
+ }, async ({ plantIds, wateredAt }) => apiSend("POST", "/api/v1/care/watering", { plantIds, wateredAt }));
204
+ server.tool("record_fertilization", "WRITE: log a fertilization for one or more plants. fertilizerId and fertilizerPercent default to each plant's own settings when omitted. Plants already fertilized that day are skipped. Requires a write-scoped API key.", {
205
+ plantIds: z.array(z.number().int().positive()).min(1).max(200).describe("Plant ids to fertilize."),
206
+ fertilizerId: z.number().int().positive().optional().describe("Override fertilizer; defaults to each plant's own."),
207
+ fertilizerPercent: z
208
+ .number()
209
+ .int()
210
+ .min(0)
211
+ .max(1000)
212
+ .optional()
213
+ .describe("Strength as a percentage of the base dose; defaults to each plant's own."),
214
+ fertilizedAt: optionalPlantDate(""),
215
+ }, async (args) => apiSend("POST", "/api/v1/care/fertilization", { ...args }));
216
+ 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.", {
217
+ plantId: z.number().int().positive().describe("Plant id."),
218
+ kind: z.enum(["observation", "issue"]).describe("'observation' for a neutral note, 'issue' for a problem."),
219
+ text: z.string().min(1).max(1000).describe("The note text."),
220
+ category: z.string().max(80).optional().describe("Optional short label, e.g. 'pests', 'growth'."),
221
+ severity: z.enum(["low", "medium", "high"]).optional().describe("For issues: how serious."),
222
+ }, async ({ plantId, kind, text, category, severity }) => apiSend("POST", `/api/v1/plants/${plantId}/health`, { kind, text, category, severity }));
223
+ 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.", {
224
+ entryId: z.number().int().positive().describe("Health entry id, from list_health_entries."),
225
+ kind: z.enum(["observation", "issue"]).optional().describe("Change the entry kind."),
226
+ text: z.string().min(1).max(1000).optional().describe("Replacement note text."),
227
+ category: z.string().max(80).optional().describe("Replacement short label, e.g. 'pests'."),
228
+ severity: z.enum(["low", "medium", "high"]).optional().describe("For issues: how serious."),
229
+ }, async ({ entryId, ...body }) => apiSend("PATCH", `/api/v1/health-entries/${entryId}`, body));
230
+ 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.", {
231
+ entryId: z.number().int().positive().describe("Health entry id, from list_health_entries."),
232
+ resolved: z.boolean().default(true).describe("true to resolve, false to reopen."),
233
+ }, async ({ entryId, resolved }) => apiSend("PATCH", `/api/v1/health-entries/${entryId}`, { resolved }));
234
+ 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.", {
235
+ plantId: z.number().int().positive().describe("Plant id the recommendation belongs to."),
236
+ type: z
237
+ .enum([
238
+ "wateringOftenLate",
239
+ "fertilizingOftenLate",
240
+ "noFertilizerThisSeason",
241
+ "recentlyRepottedAvoidFertilizer",
242
+ "noRecentPhoto",
243
+ ])
244
+ .describe("Recommendation type, from list_care_recommendations."),
245
+ fingerprint: z.string().min(1).describe("Occurrence fingerprint, from list_care_recommendations."),
246
+ }, async (args) => apiSend("POST", "/api/v1/care/recommendations/dismiss", { ...args }));
247
+ // --- Catalogs (for resolving filter ids) -----------------------------------
248
+ server.tool("list_locations", "List the user's locations.", async () => apiGet("/api/v1/locations"));
249
+ server.tool("list_plant_types", "List the user's plant types (species taxonomy).", async () => apiGet("/api/v1/plant-types"));
250
+ server.tool("list_soils", "List the user's soils.", async () => apiGet("/api/v1/soils"));
251
+ server.tool("list_fertilizers", "List the user's fertilizers.", async () => apiGet("/api/v1/fertilizers"));
252
+ // --- Boot -------------------------------------------------------------------
253
+ async function main() {
254
+ const transport = new StdioServerTransport();
255
+ await server.connect(transport);
256
+ console.error(`[plants-mcp] connected. API base: ${API_URL}`);
257
+ }
258
+ main().catch((err) => {
259
+ console.error("[plants-mcp] fatal:", err);
260
+ process.exit(1);
261
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@sebamomann/plants-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Read-only MCP server for the Sprig plant app, authenticated with a per-user API key.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "sebamomann <github@sebamomann.de>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sebamomann/plants-mcp.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/sebamomann/plants-mcp/issues"
14
+ },
15
+ "homepage": "https://github.com/sebamomann/plants-mcp#readme",
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "plants",
20
+ "sprig"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "bin": {
26
+ "plants-mcp": "dist/index.js"
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "start": "node dist/index.js",
37
+ "dev": "tsx src/index.ts",
38
+ "typecheck": "tsc --noEmit",
39
+ "prepublishOnly": "npm run build"
40
+ },
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.12.0",
43
+ "zod": "^3.24.1"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.10.0",
47
+ "tsx": "^4.19.2",
48
+ "typescript": "^5.7.2"
49
+ }
50
+ }