inaturalist-mcp 1.0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/dist/index.js +318 -0
  4. package/package.json +54 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ranfang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # iNaturalist MCP
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
+ [![Node.js 18+](https://img.shields.io/badge/node.js-18+-green.svg)](https://nodejs.org/)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-6-blue.svg)](https://www.typescriptlang.org/)
6
+
7
+ Read-only Model Context Protocol server for the public iNaturalist API.
8
+
9
+ ## Tools
10
+
11
+ - `search_observations`: Search public observations by taxon, place, user, date, location, or text.
12
+ - `get_observation`: Fetch one observation by ID.
13
+ - `search_taxa`: Search taxa by scientific name, common name, rank, or iconic taxon.
14
+ - `get_taxon`: Fetch one taxon by ID.
15
+ - `get_observation_species_counts`: Fetch species counts for observation filters.
16
+
17
+ ## Install
18
+
19
+ From npm:
20
+
21
+ ```bash
22
+ npm install -g inaturalist-mcp
23
+ ```
24
+
25
+ From source:
26
+
27
+ ```bash
28
+ npm install
29
+ npm run build
30
+ ```
31
+
32
+ ## Run
33
+
34
+ Stdio transport, for local MCP clients:
35
+
36
+ ```bash
37
+ inaturalist-mcp
38
+ ```
39
+
40
+ HTTP transport, for deployed MCP clients:
41
+
42
+ ```bash
43
+ node dist/index.js --transport http --host 127.0.0.1 --port 8890
44
+ ```
45
+
46
+ You can also configure HTTP mode with environment variables:
47
+
48
+ ```bash
49
+ MCP_TRANSPORT=http HOST=0.0.0.0 PORT=8890 node dist/index.js
50
+ ```
51
+
52
+ The HTTP MCP endpoint is:
53
+
54
+ ```text
55
+ http://127.0.0.1:8890/mcp
56
+ ```
57
+
58
+ ## Client Config
59
+
60
+ Example stdio MCP client configuration:
61
+
62
+ Using npm:
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "inaturalist": {
68
+ "command": "npx",
69
+ "args": ["-y", "inaturalist-mcp"]
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ Using a local checkout:
76
+
77
+ ```json
78
+ {
79
+ "mcpServers": {
80
+ "inaturalist": {
81
+ "command": "node",
82
+ "args": ["/path/to/inaturalist-mcp/dist/index.js"]
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ Example Streamable HTTP MCP client URL:
89
+
90
+ ```text
91
+ http://127.0.0.1:8890/mcp
92
+ ```
93
+
94
+ ## Notes
95
+
96
+ This server only wraps public read endpoints and does not require OAuth. Authenticated write operations are intentionally out of scope for the initial version.
package/dist/index.js ADDED
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
8
+ import * as z from "zod/v4";
9
+ const API_BASE_URL = "https://api.inaturalist.org/v1";
10
+ const USER_AGENT = "inaturalist-mcp/1.0 (https://github.com/ufo2243/inaturalist-mcp)";
11
+ class INaturalistApiError extends Error {
12
+ status;
13
+ body;
14
+ constructor(message, status, body) {
15
+ super(message);
16
+ this.status = status;
17
+ this.body = body;
18
+ }
19
+ }
20
+ function buildUrl(path, params = {}) {
21
+ const url = new URL(`${API_BASE_URL}/${path.replace(/^\/+/, "")}`);
22
+ for (const [key, value] of Object.entries(params)) {
23
+ if (Array.isArray(value)) {
24
+ for (const item of value) {
25
+ if (item !== undefined && item !== null && item !== "") {
26
+ url.searchParams.append(key, String(item));
27
+ }
28
+ }
29
+ continue;
30
+ }
31
+ if (value !== undefined && value !== null && value !== "") {
32
+ url.searchParams.set(key, String(value));
33
+ }
34
+ }
35
+ return url;
36
+ }
37
+ async function getJson(path, params = {}) {
38
+ const url = buildUrl(path, params);
39
+ const response = await fetch(url, {
40
+ headers: {
41
+ Accept: "application/json",
42
+ "User-Agent": USER_AGENT,
43
+ },
44
+ });
45
+ if (!response.ok) {
46
+ const body = await response.text();
47
+ throw new INaturalistApiError(`iNaturalist API request failed with HTTP ${response.status}`, response.status, body.slice(0, 1000));
48
+ }
49
+ return response.json();
50
+ }
51
+ function toolResult(data) {
52
+ return {
53
+ content: [
54
+ {
55
+ type: "text",
56
+ text: JSON.stringify(data, null, 2),
57
+ },
58
+ ],
59
+ structuredContent: data,
60
+ };
61
+ }
62
+ function errorResult(error) {
63
+ const message = error instanceof INaturalistApiError
64
+ ? `${error.message}\n\n${error.body}`
65
+ : error instanceof Error
66
+ ? error.message
67
+ : String(error);
68
+ return {
69
+ isError: true,
70
+ content: [
71
+ {
72
+ type: "text",
73
+ text: message,
74
+ },
75
+ ],
76
+ };
77
+ }
78
+ const paginationSchema = {
79
+ page: z.number().int().min(1).optional().describe("Page number. Defaults to 1."),
80
+ per_page: z
81
+ .number()
82
+ .int()
83
+ .min(1)
84
+ .max(50)
85
+ .optional()
86
+ .describe("Results per page, capped at 50. Defaults to iNaturalist's API default."),
87
+ };
88
+ function createServer() {
89
+ const server = new McpServer({
90
+ name: "inaturalist-mcp",
91
+ version: "1.0.0",
92
+ });
93
+ server.registerTool("search_observations", {
94
+ description: "Search public iNaturalist observations by taxon, place, user, date, location, or free text.",
95
+ inputSchema: {
96
+ q: z.string().optional().describe("Free-text search query."),
97
+ taxon_id: z.number().int().positive().optional().describe("iNaturalist taxon ID."),
98
+ place_id: z.number().int().positive().optional().describe("iNaturalist place ID."),
99
+ user_id: z.string().optional().describe("iNaturalist username or user ID."),
100
+ lat: z.number().min(-90).max(90).optional().describe("Latitude for geo search."),
101
+ lng: z.number().min(-180).max(180).optional().describe("Longitude for geo search."),
102
+ radius: z.number().positive().optional().describe("Radius in kilometers when lat/lng are provided."),
103
+ d1: z.string().optional().describe("Observed date lower bound, YYYY-MM-DD."),
104
+ d2: z.string().optional().describe("Observed date upper bound, YYYY-MM-DD."),
105
+ observed_on: z.string().optional().describe("Exact observed date, YYYY-MM-DD."),
106
+ quality_grade: z
107
+ .enum(["casual", "needs_id", "research"])
108
+ .optional()
109
+ .describe("Observation quality grade."),
110
+ order_by: z
111
+ .enum(["created_at", "observed_on", "species_guess", "votes", "id"])
112
+ .optional()
113
+ .describe("Sort field."),
114
+ order: z.enum(["asc", "desc"]).optional().describe("Sort direction."),
115
+ ...paginationSchema,
116
+ },
117
+ }, async (args) => {
118
+ try {
119
+ return toolResult(await getJson("observations", args));
120
+ }
121
+ catch (error) {
122
+ return errorResult(error);
123
+ }
124
+ });
125
+ server.registerTool("get_observation", {
126
+ description: "Get one public iNaturalist observation by observation ID.",
127
+ inputSchema: {
128
+ id: z.number().int().positive().describe("iNaturalist observation ID."),
129
+ },
130
+ }, async ({ id }) => {
131
+ try {
132
+ return toolResult(await getJson(`observations/${id}`));
133
+ }
134
+ catch (error) {
135
+ return errorResult(error);
136
+ }
137
+ });
138
+ server.registerTool("search_taxa", {
139
+ description: "Search iNaturalist taxa by name, common name, rank, or iconic taxon.",
140
+ inputSchema: {
141
+ q: z.string().optional().describe("Taxon name or common name query."),
142
+ rank: z
143
+ .enum(["kingdom", "phylum", "class", "order", "family", "genus", "species", "subspecies"])
144
+ .optional()
145
+ .describe("Taxonomic rank filter."),
146
+ iconic_taxa: z
147
+ .string()
148
+ .optional()
149
+ .describe("Comma-separated iconic taxa, e.g. Aves,Mammalia,Plantae."),
150
+ locale: z.string().optional().describe("Locale for common names, e.g. en, zh-CN."),
151
+ ...paginationSchema,
152
+ },
153
+ }, async (args) => {
154
+ try {
155
+ return toolResult(await getJson("taxa", args));
156
+ }
157
+ catch (error) {
158
+ return errorResult(error);
159
+ }
160
+ });
161
+ server.registerTool("get_taxon", {
162
+ description: "Get one iNaturalist taxon by taxon ID.",
163
+ inputSchema: {
164
+ id: z.number().int().positive().describe("iNaturalist taxon ID."),
165
+ locale: z.string().optional().describe("Locale for common names, e.g. en, zh-CN."),
166
+ },
167
+ }, async ({ id, locale }) => {
168
+ try {
169
+ return toolResult(await getJson(`taxa/${id}`, { locale }));
170
+ }
171
+ catch (error) {
172
+ return errorResult(error);
173
+ }
174
+ });
175
+ server.registerTool("get_observation_species_counts", {
176
+ description: "Get species counts from public iNaturalist observations for a place, taxon, user, date range, or location.",
177
+ inputSchema: {
178
+ taxon_id: z.number().int().positive().optional().describe("iNaturalist taxon ID."),
179
+ place_id: z.number().int().positive().optional().describe("iNaturalist place ID."),
180
+ user_id: z.string().optional().describe("iNaturalist username or user ID."),
181
+ lat: z.number().min(-90).max(90).optional().describe("Latitude for geo search."),
182
+ lng: z.number().min(-180).max(180).optional().describe("Longitude for geo search."),
183
+ radius: z.number().positive().optional().describe("Radius in kilometers when lat/lng are provided."),
184
+ d1: z.string().optional().describe("Observed date lower bound, YYYY-MM-DD."),
185
+ d2: z.string().optional().describe("Observed date upper bound, YYYY-MM-DD."),
186
+ locale: z.string().optional().describe("Locale for common names, e.g. en, zh-CN."),
187
+ ...paginationSchema,
188
+ },
189
+ }, async (args) => {
190
+ try {
191
+ return toolResult(await getJson("observations/species_counts", args));
192
+ }
193
+ catch (error) {
194
+ return errorResult(error);
195
+ }
196
+ });
197
+ return server;
198
+ }
199
+ function getArgValue(name) {
200
+ const index = process.argv.indexOf(name);
201
+ return index >= 0 ? process.argv[index + 1] : undefined;
202
+ }
203
+ function getTransportMode() {
204
+ const value = getArgValue("--transport") ?? process.env.MCP_TRANSPORT ?? "stdio";
205
+ if (value === "stdio" || value === "http") {
206
+ return value;
207
+ }
208
+ throw new Error(`Unsupported transport "${value}". Use "stdio" or "http".`);
209
+ }
210
+ function getPort() {
211
+ const value = getArgValue("--port") ?? process.env.PORT ?? "3000";
212
+ const port = Number(value);
213
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
214
+ throw new Error(`Invalid port "${value}".`);
215
+ }
216
+ return port;
217
+ }
218
+ function getHost() {
219
+ return getArgValue("--host") ?? process.env.HOST ?? "127.0.0.1";
220
+ }
221
+ async function runStdio() {
222
+ const server = createServer();
223
+ const transport = new StdioServerTransport();
224
+ await server.connect(transport);
225
+ console.error("iNaturalist MCP server running on stdio");
226
+ }
227
+ async function runHttp() {
228
+ const host = getHost();
229
+ const port = getPort();
230
+ const app = createMcpExpressApp({ host });
231
+ const transports = {};
232
+ app.post("/mcp", async (req, res) => {
233
+ const sessionId = req.headers["mcp-session-id"];
234
+ try {
235
+ let transport;
236
+ if (typeof sessionId === "string" && transports[sessionId]) {
237
+ transport = transports[sessionId];
238
+ }
239
+ else if (!sessionId && isInitializeRequest(req.body)) {
240
+ transport = new StreamableHTTPServerTransport({
241
+ sessionIdGenerator: () => randomUUID(),
242
+ onsessioninitialized: (newSessionId) => {
243
+ transports[newSessionId] = transport;
244
+ },
245
+ });
246
+ transport.onclose = () => {
247
+ const closedSessionId = transport.sessionId;
248
+ if (closedSessionId) {
249
+ delete transports[closedSessionId];
250
+ }
251
+ };
252
+ const server = createServer();
253
+ await server.connect(transport);
254
+ }
255
+ else {
256
+ res.status(400).json({
257
+ jsonrpc: "2.0",
258
+ error: {
259
+ code: -32000,
260
+ message: "Bad Request: missing session ID or initialize request",
261
+ },
262
+ id: null,
263
+ });
264
+ return;
265
+ }
266
+ await transport.handleRequest(req, res, req.body);
267
+ }
268
+ catch (error) {
269
+ console.error("Error handling MCP request:", error);
270
+ if (!res.headersSent) {
271
+ res.status(500).json({
272
+ jsonrpc: "2.0",
273
+ error: {
274
+ code: -32603,
275
+ message: "Internal server error",
276
+ },
277
+ id: null,
278
+ });
279
+ }
280
+ }
281
+ });
282
+ app.get("/mcp", async (req, res) => {
283
+ const sessionId = req.headers["mcp-session-id"];
284
+ if (typeof sessionId !== "string" || !transports[sessionId]) {
285
+ res.status(400).send("Invalid or missing session ID");
286
+ return;
287
+ }
288
+ await transports[sessionId].handleRequest(req, res);
289
+ });
290
+ app.delete("/mcp", async (req, res) => {
291
+ const sessionId = req.headers["mcp-session-id"];
292
+ if (typeof sessionId !== "string" || !transports[sessionId]) {
293
+ res.status(400).send("Invalid or missing session ID");
294
+ return;
295
+ }
296
+ await transports[sessionId].handleRequest(req, res);
297
+ });
298
+ const httpServer = app.listen(port, host, () => {
299
+ console.error(`iNaturalist MCP server listening at http://${host}:${port}/mcp`);
300
+ });
301
+ process.on("SIGINT", async () => {
302
+ for (const transport of Object.values(transports)) {
303
+ await transport.close();
304
+ }
305
+ httpServer.close(() => process.exit(0));
306
+ });
307
+ }
308
+ async function main() {
309
+ if (getTransportMode() === "http") {
310
+ await runHttp();
311
+ return;
312
+ }
313
+ await runStdio();
314
+ }
315
+ main().catch((error) => {
316
+ console.error("Server error:", error);
317
+ process.exit(1);
318
+ });
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "inaturalist-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Read-only Model Context Protocol server for the iNaturalist API.",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "README.md",
9
+ "LICENSE"
10
+ ],
11
+ "bin": {
12
+ "inaturalist-mcp": "dist/index.js"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "main": "dist/index.js",
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "prepublishOnly": "npm run build",
21
+ "start": "node dist/index.js",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/ufo2243/inaturalist-mcp.git"
27
+ },
28
+ "keywords": [
29
+ "mcp",
30
+ "model-context-protocol",
31
+ "inaturalist",
32
+ "biodiversity"
33
+ ],
34
+ "author": "ranfang",
35
+ "license": "MIT",
36
+ "bugs": {
37
+ "url": "https://github.com/ufo2243/inaturalist-mcp/issues"
38
+ },
39
+ "homepage": "https://github.com/ufo2243/inaturalist-mcp#readme",
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "registry": "https://registry.npmjs.org/"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.29.0",
46
+ "express": "^5.2.1",
47
+ "zod": "^4.4.3"
48
+ },
49
+ "devDependencies": {
50
+ "@types/express": "^5.0.6",
51
+ "@types/node": "^25.6.0",
52
+ "typescript": "^6.0.3"
53
+ }
54
+ }