Search iNaturalist sightings, identification threads, phenology, and look-alike species.
Search wildlife sightings, read identification threads, rank species by area, chart phenology, and find look-alike taxa from iNaturalist via MCP. STDIO or Streamable HTTP.
Public Hosted Server: https://inaturalist.caseyjhand.com/mcp
iNaturalist's index of 380M+ georeferenced citizen-science observations of plants, animals, and fungi. Search sightings by area, date, taxon, and annotation; read the community identification thread behind a record; chart when a taxon appears in a place; rank the species of an area; and check what a look-alike is most often confused with. Keyless and read-only, running as a stdio process, a local Streamable HTTP server, or the public hosted endpoint above.
Composes with servers covering institutional specimen records, botanical nomenclature, and geocoding — this one contributes the observation, identification-thread, and phenology layer.
| Tool | Description |
|---|---|
inaturalist_list_reference | Decode the controlled vocabularies the other tools filter on — annotation attributes and values, quality grades, licences, ranks, iconic taxa, conservation-status codes |
inaturalist_resolve_name | Resolve a common or scientific name to a taxon id, or a place, project, or observer name to its id, as ranked candidates |
inaturalist_find_places | Resolve a place name to a place id, or list the places covering a map area, each with its bounding box and containment chain |
inaturalist_search_observations | Search georeferenced sightings by area, date, taxon, quality grade, annotation, and conservation status |
inaturalist_get_observation | Fetch up to 10 observations by id with their community identification thread and consensus taxon |
inaturalist_get_species_counts | Rank the distinct species recorded in an area and period, most-observed first |
inaturalist_get_histogram | Build a phenology histogram for a taxon in an area — which months, weeks, or years it is recorded in |
inaturalist_get_leaderboard | Rank the most active observers or identifiers for an area, period, and taxon |
inaturalist_get_similar_species | List the taxa a taxon is most often misidentified as, ranked by how many times identifiers made the correction |
inaturalist_get_taxon | Fetch a taxon profile — taxonomic path, conservation listings by authority, encyclopedia summary, photos, and children |
| Resource | Description |
|---|---|
inaturalist://taxa/{taxon_id} | Taxon profile by numeric taxon id, as injectable context |
inaturalist://observations/{observation_id} | One observation with its identification thread expanded, as injectable context |
Both resources mirror data also reachable through inaturalist_get_taxon and inaturalist_get_observation — useful for clients that don't surface MCP resources.
inaturalist_list_reference tooltopic selects one table: controlled_terms, quality_grades, licenses, ranks, iconic_taxa, conservation_status_codes; source reports whether it came from iNaturalist or the published spectaxon_id applies only to controlled_terms and adds observed_usage — which annotation pairs identifiers have actually recorded for that taxon, with countsinaturalist_resolve_name tooltype: taxon (name-prefix autocomplete) or place / project / user / any (scored cross-kind search); rank narrows taxa only; limit 1–30 (default 10), applied in-process on every typefound: false with guidance naming why, rather than an errorkind and id — the identifier every other tool takes. A user candidate also carries login, the value leaderboard entries and an observation's observer relay; its name is the display nameinaturalist_find_places toolq (place-name prefix) or all four of nelat, nelng, swlat, swlng; neither or both fails as invalid_geography, as does a box with nelat south of swlat. A blank q reads as unset, and nelng west of swlng is an antimeridian-crossing box, not an errorq returns places[]; the bounding box returns standard[] and community[] as separate listsbbox, place_type, admin_level, ancestor_place_ids, location, and slug; boundary polygons are stripped, since one upstream response carries 247 KB of themper_page (1–30, default 10) binds the bounding-box arm only, where it bounds standard[] and community[] separately, so cap is per_page × 2. The name-prefix endpoint publishes no page size, and its fixed page is disclosed through the truncation enrichmentinaturalist_search_observations toolplace_id, the lat+lng+radius triple in kilometres (0 < radius ≤ 500), or the four-corner bounding box with nelat at or north of swlat; partial, mixed, a zero radius, or an inverted box fails as invalid_geographytaxon_id, d1/d2, quality_grade, captive, term_id+term_value_id, iconic_taxa, hrank/lrank, csi, threatened/native/introduced/endemic, licensed/photo_licensed, and q+search_ond1 after d2 fails as inverted_date_range (on every tool that takes dates), and an hrank finer than lrank as inverted_rank_range. Equal pairs are validquality_grade: ["research"] and captive: false, echoed back as applied_filters on every callper_page 1–25 (default 10); page walks the first 10,000 results and cursor continues past it — passing both fails, and a cursor forces an id ordering, which is echoedinclude expands photos, annotations, sounds. identifications and comments are deliberately absent — one thread measures 28 KB, so the thread lives on inaturalist_get_observationinaturalist_get_observation toolinclude defaults to ["identifications"]; comments, photos, annotations, and sounds are also availableobservations, the rest in unresolved; the call fails as not_found only when nothing resolvedcommunity_taxon and identification_disagreements_count on top of the projected search recordinaturalist_get_species_counts toolobservation_count — the "what lives here" answer without paging through individual sightingstaxon_id narrows to a clade, such as the birds of a parkper_page 1–50 (default 25), page for offset — upstream would serve 500 in one page, and the cap is sized by response bytes insteadtruncationCeiling carries the last count shown; the ranking is descending, so nothing left off the page exceeds itinaturalist_get_histogram toolinterval: month_of_year (default) and week_of_year fold every year into one seasonal curve; year, month, week, day, and hour bucket absolute dates, to which upstream applies its own default start datedate_field: observed (default) or createdtaxon_id is optional — omit it to chart every taxon in the areatotal — computed across every bucket upstream returned, even past the cap. day/hour over a wide date range can generate thousands of buckets, so the response is capped at 800, kept from the start of the range, with truncated/shown/cap disclosing the cutinaturalist_get_leaderboard toolkind: observers (ranked by observations recorded, carrying species_count) or identifiers (identifications made); count_metric names what count measuresper_page 1–250 (default 25), page for offset. Both endpoints rank only the top 500, so page × per_page past 500 fails as leaderboard_window_exceeded rather than returning a false zero-hittaxon_id, d1/d2, and quality_grade as the observation searchinaturalist_get_similar_species tooltaxon_id is most often corrected from, ranked by misidentification_count — the field-identification check before committing to a look-alikequality_grade, and captive scope the confusion set to one region; omit them for the global setlimit 1–50 (default 20), applied in-process — the endpoint publishes no page size and returns its whole setinaturalist_get_taxon tooltaxon_id. Returns kind: "full" with the projected profile, or kind: "outline" listing each section and its byte size when the projection still overflows the budgetsummary, taxonomy, children, conservation, photos, encyclopedia; name them in sections to fetch a slice, and an unknown name fails as unknown_sectionlisted_taxa_count kept as a scalar, and ancestors, children, and conservation entries trimmed to their identifying fieldsinaturalist://taxa/{taxon_id} resourceapplication/json, always whole — a resource read has no way to name sections, so use inaturalist_get_taxon when the outline path matterstaxon_id comes from inaturalist_resolve_name; cached for six hours, matching the service's taxon TTLinaturalist://observations/{observation_id} resourceapplication/jsonobservation_id comes from inaturalist_search_observations; cached for fifteen minutes, since a thread accrues identificationsBuilt on @cyanheads/mcp-ts-core: stdio and Streamable HTTP transports, pluggable auth (none / jwt / oauth), swappable storage (in-memory, filesystem, Supabase, Cloudflare KV/R2/D1), structured logging with optional OpenTelemetry tracing.
iNaturalist-specific:
fields= parameter, so a two-record observation search arrives at 95 KB, a full upstream page of 200 at 4.3 MB, and a common taxon record at 95 KB before anything is trimmedlat, an unparseable or impossible d1 such as 2026-02-30, d1 after d2, a term_value_id without its term_id, a zero radius, a box with nelat south of swlat, a page past the result windowAgent-friendly output:
quality_grade, captive, and the ordering a cursor forced — so an agent can see the filters that shaped its answertruncated, shown, and cap on every path, plus a truncationCeiling where a descending ranking supports oneThe API is open; the records are not uniformly open.
license_code is relayed verbatim and is nullable. A null means all rights reserved — it is never coerced to "", "unknown", or a default licence, and the rendered text spells the null case out in words.attribution strings are relayed verbatim, never reformatted or shortened, and must be reproduced wherever the image is. A photo's own license_code is independent of its observation's.open is derived from the hosting domain: true for inaturalist-open-data.s3.amazonaws.com, false for anything else, because an unrecognised host is not evidence of an open licence. A licence change moves a photo between hosts, so the flag describes fetch time rather than a permanent property.obscured: true marks a locality, not a sighting position. iNaturalist withholds true coordinates for threatened taxa, and the server never sends an Authorization header, so hidden coordinates stay hidden.login. The upstream user object carries a real name, an ORCID, and counts; none of it is relayed.The published terms allow at most 100 requests per minute, ask clients to stay at or below 60, and ask for under 10,000 per day. No rate-limit headers come back, so pacing is entirely self-imposed: outbound requests start at least INATURALIST_MIN_REQUEST_INTERVAL_MS apart (1100 ms ≈ 54 per minute), at most INATURALIST_MAX_CONCURRENT_REQUESTS run in flight, and INATURALIST_DAILY_REQUEST_BUDGET bounds a UTC day. Exhausting the budget is a typed failure rather than a silent degradation.
Controlled terms (24 h), taxon profiles (6 h), places (6 h), the similar-species graph (6 h), and histograms (1 h) are cached in tenant-scoped storage. Observation search, species counts, leaderboards, and observation detail are never cached — freshness is what they are for.
Page-size maxima are sized by measured response bytes across structuredContent and the rendered text together, not by what upstream will serve:
| Tool | Bytes per record | per_page max | Default | Upstream would serve |
|---|---|---|---|---|
inaturalist_search_observations | ~1,970 | 25 | 10 | 200 |
inaturalist_get_species_counts | ~860 | 50 | 25 | 500 |
inaturalist_get_leaderboard | ~140 | 250 | 25 | 500 |
Each default page fits the 24,000-byte budget a single document gets, and each full page fits 50,000. Nothing is unreachable at the lower caps — page and cursor reach the same records — so the smaller page costs one more call rather than any data.
fields= partial-response parameter, so every byte is fetched before being projected away. Projection saves the agent's context, not the network.total_results is an estimate over a live index. It drifts between calls seconds apart.place_type and admin_level have no published code table. The raw integers are relayed and display_name carries the meaning.inaturalist_get_taxon comes back whole however large it is — truncating a section the caller asked for by name is the failure the outline exists to prevent.inaturalist_get_leaderboard can address only the top 500 entries, against the 10,000-result window on observation search.A public instance is available at https://inaturalist.caseyjhand.com/mcp — no installation required. Point any MCP client at it via Streamable HTTP:
{
"mcpServers": {
"inaturalist-mcp-server": {
"type": "streamable-http",
"url": "https://inaturalist.caseyjhand.com/mcp"
}
}
}
Add the following to your MCP client configuration file.
{
"mcpServers": {
"inaturalist-mcp-server": {
"type": "stdio",
"command": "bunx",
"args": ["@cyanheads/inaturalist-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info"
}
}
}
}
Or with npx (no Bun required):
{
"mcpServers": {
"inaturalist-mcp-server": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@cyanheads/inaturalist-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info"
}
}
}
}
Or with Docker:
{
"mcpServers": {
"inaturalist-mcp-server": {
"type": "stdio",
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/inaturalist-mcp-server:latest"]
}
}
}
For Streamable HTTP, set the transport and start the server:
MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http
# Server listens at http://localhost:3010/mcp
User-Agent with a contact URL is sent by default; keep one in any INATURALIST_USER_AGENT override.git clone https://github.com/cyanheads/inaturalist-mcp-server.git
cd inaturalist-mcp-server
bun install
cp .env.example .env
# edit .env to override defaults — no required vars
| Variable | Description | Default |
|---|---|---|
MCP_TRANSPORT_TYPE | Transport: stdio or http | stdio |
MCP_HTTP_PORT | HTTP server port | 3010 |
MCP_HTTP_ENDPOINT_PATH | HTTP endpoint path where the MCP server is mounted | /mcp |
MCP_SESSION_MODE | HTTP session posture: stateless, stateful, or auto. Overrides the stateless declared in src/index.ts. | stateless |
MCP_PUBLIC_URL | Public origin override for TLS-terminating reverse-proxy deployments | none |
MCP_AUTH_MODE | Authentication: none, jwt, or oauth | none |
MCP_LOG_LEVEL | Log level (debug, info, notice, warning, error) | info |
MCP_GC_PRESSURE_INTERVAL_MS | Opt-in Bun-only forced-GC pressure loop (ms). Recommended starting point if heap growth is observed: 60000. | 0 (disabled) |
LOGS_DIR | Directory for log files (Node.js only) | <project-root>/logs |
STORAGE_PROVIDER_TYPE | Storage backend: in-memory, filesystem, supabase, cloudflare-kv/r2/d1. Backs the response cache. | in-memory |
INATURALIST_USER_AGENT | User-Agent sent on every request to api.inaturalist.org. Keep a contact URL in any override. | inaturalist-mcp-server/<version> (+<repo url>) |
INATURALIST_MIN_REQUEST_INTERVAL_MS | Minimum spacing between outbound request starts, in milliseconds. | 1100 |
INATURALIST_MAX_CONCURRENT_REQUESTS | Maximum outbound requests in flight. | 4 |
INATURALIST_DAILY_REQUEST_BUDGET | Outbound requests allowed per UTC day, counted in-process. | 9000 |
OTEL_ENABLED | Enable OpenTelemetry | false |
See .env.example for the full list of optional overrides.
Build and run the production version:
# One-time build
bun run rebuild
# Run the built server
bun run start:stdio
# or
bun run start:http
Run checks and tests:
bun run devcheck # Lint, format, typecheck, security
bun run test # Vitest test suite
bun run lint:mcp # Validate MCP definitions against spec
docker build -t inaturalist-mcp-server .
docker run --rm -p 3010:3010 inaturalist-mcp-server
The Dockerfile defaults to HTTP transport, stateless session mode, and logs to /var/log/inaturalist-mcp-server. OpenTelemetry peer dependencies are installed by default — build with --build-arg OTEL_ENABLED=false to omit them.
| Directory | Purpose |
|---|---|
src/index.ts | createApp() entry point — registers tools and resources, inits the service. |
src/config | Server-specific environment variable parsing and validation with Zod. |
src/mcp-server/tools | Tool definitions (*.tool.ts) plus the shared filter, record, and taxon-document helpers. |
src/mcp-server/resources | Resource definitions (*.resource.ts) — taxon and observation. |
src/services/inaturalist | iNaturalist service layer — allowlisted client, pacer, cache, and response projections. |
tests/ | Unit and integration tests mirroring the src/ structure. |
See CLAUDE.md for development guidelines and architectural rules. The short version:
try/catch in tool logicctx.log for logging, ctx.state for storagecreateApp() arraysIssues are welcome. Run checks and tests before submitting:
bun run devcheck
bun run test
This project is licensed under the Apache 2.0 License. See the LICENSE file for details.
Source-derived launch command. Check the maintainer’s required arguments and credentials before running:
npx -y @cyanheads/inaturalist-mcp-serverMerge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.
{
"mcpServers": {
"io-github-cyanheads-inaturalist-mcp-server": {
"command": "npx",
"args": [
"-y",
"@cyanheads/inaturalist-mcp-server"
]
}
}
}Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.
Claude Desktop setup referenceio.github.cyanheads/inaturalist-mcp-server works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.
~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.~/.cursor/mcp.jsonRestart Cursor for changes to take effect..vscode/mcp.jsonReload VS Code window for changes to take effect.~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect..mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.