@cyanheads/inaturalist-mcp-server
Search wildlife sightings, read identification threads, rank taxa by area, chart phenology, and find look-alike taxa from iNaturalist via MCP. STDIO or Streamable HTTP.
10 Tools • 2 Domain Resources • 1 Skill
Overview
iNaturalist's index of 387M+ georeferenced citizen-science observations of plants, animals, and fungi. Search sightings by area, date, taxon, and annotation; read the identification thread behind a record; chart when a taxon appears; rank the taxa of an area; and explore related taxa through shared identification evidence. No API key required.
Tools
Resources
Both resources mirror data also reachable through inaturalist_get_taxon and inaturalist_get_observation, for clients that don't surface MCP resources.
The published inaturalist-guide covers identifier provenance, matched cohorts, annotations, and evidence coverage across calls. It ships with npm, plugin, MCPB, and Docker installs; clients supporting MCP skill discovery can retrieve it directly. The SDK also exposes skill resources alongside the two domain resources.
Capability reference
inaturalist_list_reference tool
topic picks one table: controlled_terms (fetched live), quality_grades, licenses, ranks, iconic_taxa, or conservation_status_codes; taxon_id, valid only with controlled_terms, adds observed_usage, the annotation pairs recorded for that taxon with counts
source is upstream or static; annotation attributes and their values carry numeric ids to pass as term_id and term_value_id, since labels repeat across attributes
inaturalist_resolve_name tool
q plus type: taxon (default; word-start name matching or exact numeric ID, with optional rank) or place / project / user / any (scored cross-kind search with limited spelling tolerance); applied limit up to 30, default 10
- Omit
page for relevance-ranked suggestions. Explicit taxon name pages browse ascending taxon IDs from page 1; their multilingual match population can differ from autocomplete. Keep the limit fixed; page × applied limit must stay within 10,000. Numeric taxon IDs have one page
- Each candidate carries
kind and id, plus login on users. A true miss returns found: false with guidance; an empty page beyond matching records retains found: true. Every reply discloses totalCount, truncated, shown, and cap
inaturalist_find_places tool
- Exactly one of
q (display-name prefix or analyzed words) or all four of nelat, nelng, swlat, swlng, otherwise invalid_geography; per_page is forwarded on both arms, default 10, served maximum 20 per list. Strict mode accepts up to 30, then applies the same served ceiling
q returns places[], a box returns standard[] and community[]; each place carries id, bbox, ancestor_place_ids, place_type, admin_level, and location, with boundary polygons stripped
- Name autocomplete has no pagination; narrow the text or use a smaller map area to explore further matches.
cap is the applied size for names and twice that size for a box's two lists, including empty replies
inaturalist_search_observations tool
- One area form plus
taxon_id, d1/d2, quality_grade, captive, term_id+term_value_id, iconic_taxa, hrank/lrank, csi, threatened/native/introduced/endemic, user_id or user_login, project_id, and q+search_on; applied per_page up to 25, default 10; optional sounds filters for audio-bearing records, while include expands photos, annotations, sounds (identification threads live on inaturalist_get_observation)
page walks the first 10,000 results; past that, order by id descending and pass each page's next_cursor as cursor. Returns total_results (a live estimate) and has_more, and echoes applied_filters
license / photo_license take the seven CC codes, OR-joined; photo_license matches any photo under the code, independent of the record's own license_code
- The complete serialized reply stays within 50,000 UTF-8 bytes by returning a prefix of whole records. Ordered
deferred observation IDs identify the suffix; fetch them with inaturalist_get_observation and the same include before following the upstream page continuation
acc_below accepts positive fractional metres up to 2,147,483,647 and selects measured accuracy strictly below the threshold, excluding missing accuracy. obscuration accepts obscured, private, and none; it checks both observer and taxon geoprivacy, with none requiring both to be open/unset. month accepts observed calendar months 1–12
created_d1/created_d2 independently bound upload dates; updated_since accepts a calendar date or ISO datetime with timezone. identifications selects most_agree, some_agree, or most_disagree. Search, by-id records, and observation resources preserve nullable created_at and updated_at strings
inaturalist_get_observation tool
- Submit 1–100
observation_ids: duplicates are removed in first-seen order, the first ten distinct IDs are attempted in one upstream request, and the rest return in deferred; with limit clamping disabled, at most ten submitted IDs are accepted. include defaults to ["identifications"], with comments, photos, annotations, and sounds available
- Resolved records come back in
observations in request order, attempted misses in unresolved; fails as not_found only when none resolve and nothing is deferred. Adds community_taxon, description, and filled observation_fields; strings remain complete, so detail replies have no universal byte ceiling
- Identifications, comments, and observation fields share a 40-entry budget: each record keeps
max(4, floor(40 / records returned)) entries per array and reports *_total beside *_shown
inaturalist_get_species_counts tool
- Counts leaf taxa, including hybrids and coarser identifications; subspecies roll up to their species.
total_results counts taxa, and the species field retains its upstream name.
introduced, native, threatened, endemic, csi, hrank/lrank, and observed month (integer array, 1–12) are shared with search. rank selects one exact rank; equal rank-band bounds select a level, including both species and hybrid at level 10. Conservation codes match global listings plus listings at exactly place_id, excluding ancestor-place listings. Status flags describe the observation's indexed taxon at its location.
- One area form plus the search filters for taxon, dates, quality grade, captive, annotations, iconic taxa, observer, and project; applied
per_page up to 50, default 25, with page offset
- Rows carry
position (absolute, counted from page 1), taxon_id, and observation_count; truncationCeiling is the last count shown, which nothing off the page exceeds
inaturalist_get_histogram tool
interval: month_of_year (default) or week_of_year for a seasonal curve, or year / month / week / day / hour for absolute dates; date_field observed (default) or created; taxon_id optional, and an annotation pair (Life Stage 1 = Larva 6) or iconic_taxa narrows the curve
- Returns up to 25,000 buckets in order, zeros included, with
truncated / shown / cap. total sums every upstream bucket in the histogram window, including omitted rows; it is not an all-time taxon count. The row cap is no byte guarantee
d1/d2 bound observation dates; created_d1/created_d2 separately bound upload dates. date_field selects which date is bucketed without changing either filter pair
- Absolute intervals supply only the selected family's missing lower bound, anchored to its upper bound or today UTC: year/month subtract 100 calendar years, week 10 years, day one year, hour seven days.
applied_filters.window_start discloses that effective start; no upper bound is added. Seasonal intervals have no generated start
- On truncation, follow the first-omitted-bucket guidance with other filters and upper bounds retained. Date-only continuation can overlap earlier or partial boundary buckets: deduplicate keys, preserve earlier counts, and never add or replace repeated counts. This cannot reconstruct complete boundary-bucket totals
user_id or user_login selects an observer's sightings; project_id selects a project. Resolve either with inaturalist_resolve_name; an observer ID and login are mutually exclusive
inaturalist_get_leaderboard tool
kind: observers or identifiers, with one area form, taxon_id, d1/d2, and quality_grade; applied per_page up to 250, default 25
- Only the top 500 are addressable, so
page × per_page past 500 fails as leaderboard_window_exceeded; entries carry rank, login, and count (measured per count_metric), plus species_count for observers
- A full final page reports
truncated: false when the matching set is exhausted
user_id or user_login and project_id scope the underlying observations. On observers, an observer selector returns that observer's own row; on identifiers, it returns people who identified that observer's sightings. It does not select identifications authored by the named observer
inaturalist_get_similar_species tool
taxon_id at genus or finer, otherwise taxon_rank_too_coarse; an optional area, d1/d2, quality_grade, and captive scope the evidence; default limit 20, served maximum 24. Strict mode accepts up to 50, then applies the same served ceiling
- The retained
misidentification_count counts observations carrying current, non-hidden identifications of both taxa, including ancestor taxa. This is symmetric co-identification evidence, not directional corrections or visual similarity. truncationCeiling describes a locally cut ranking; upstream may omit further taxa and offers no pagination
inaturalist_get_taxon tool
- One
taxon_id; sections selects from summary, taxonomy, children, conservation, photos, encyclopedia, and an unknown name fails as unknown_section
kind: "full" carries the profile; kind: "outline" lists each section with its byte size when the profile overflows. A named section always comes back whole, so sum the outline's sizes before asking for several
inaturalist://taxa/{taxon_id} resource
- The whole projected taxon document as
application/json; a resource read can't name sections, so use inaturalist_get_taxon for the outline path
taxon_id comes from inaturalist_resolve_name; cached for six hours
inaturalist://observations/{observation_id} resource
- One observation as
application/json with its first 40 identifications and first 40 filled observation fields, each with its _total beside its _shown
observation_id comes from inaturalist_search_observations; cached for fifteen minutes
Features
Built 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:
- Keyless, read-only coverage of the iNaturalist v1 API. Upstream ignores its own
fields= parameter, so every response is projected in-process (a common taxon record arrives at 95 KB)
- Area-scoped tools take one area form:
place_id, lat+lng+radius in kilometres (clamped at 20,040 by default; 500 maximum in strict mode), or a four-corner box. The geographic filter is retained at the ceiling. Partial or mixed forms fail as invalid_geography, and d1 after d2 as inverted_date_range
- Per-endpoint parameter allowlist, plus in-process rejection of input upstream would silently widen or zero out: a lone
term_value_id, an impossible date, an unknown licence code
- Self-paced traffic: spaced request starts, capped concurrency, and a per-UTC-day budget that fails as
rate_budget_exhausted. The counter is per process, so every caller of the hosted instance shares it. Taxa, places, controlled terms, histograms, and look-alike sets are cached; observation queries are not
- Licences relayed verbatim: a null
license_code means all rights reserved, photo attribution must travel with the image, and photos are linked, never proxied. An obscured coordinate is a locality, and observers are identified by login only
Agent-friendly output:
- Applied defaults echoed: search, species counts, histograms, and leaderboards return
applied_filters (research-grade and wild-only by default, plus any ordering a cursor forced). captive: true selects captive or cultivated records only; false selects wild records only
- Zero-hit notices name the filter most likely responsible and the tool that decodes it (
inaturalist_list_reference for vocabularies, since an unrecognised value returns nothing); a page past the end names the last page with results
- Truncation disclosed on every path:
truncated, shown, and cap, plus truncationCeiling on descending rankings
- Empty filter arrays are ignored with a notice; nonempty annotation values still require a nonempty attribute array.
include: [] retains its explicit expansion selection
- An unknown
place_id returns typed unknown_place_id guidance to resolve it with inaturalist_find_places
- Upstream free text renders as literal quoted content, preserving line separators, HTML, entities, and complete strings after CommonMark parsing. NUL-containing strings use a labeled JSON string literal; URL fields render as links. Present empty expansions, zero counts, and null or empty descriptions remain visible
Getting started
Public Hosted Instance
A public instance is available at https://inaturalist.caseyjhand.com/mcp — no installation required. Point any MCP client at it via Streamable HTTP:
Self-Hosted / Local
Add the following to your MCP client configuration file.
Or with npx (no Bun required):
Or with Docker:
For Streamable HTTP, set the transport and start the server:
Prerequisites
- Bun v1.4.0 or higher (or Node.js v24+).
- No API key: the iNaturalist v1 API is keyless. The published terms ask clients to identify themselves, so a
User-Agent with a contact URL is sent by default; keep one in any INATURALIST_USER_AGENT override.
Installation
- Clone the repository:
- Navigate into the directory:
- Install dependencies:
- Configure environment (optional):
Configuration
See .env.example for the full list of optional overrides.
Running the server
Local development
Docker
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.
Project structure
Development guide
See CLAUDE.md for development guidelines and architectural rules. The short version:
- Handlers throw, framework catches — no
try/catch in tool logic
- Use
ctx.log for logging, ctx.state for storage
- Register new tools and resources in the
createApp() arrays in src/index.ts
- Wrap external API calls: validate raw → normalize to domain type → return output schema; never fabricate missing fields
Contributing
Issues are welcome. Run checks and tests before submitting:
License
This project is licensed under the Apache 2.0 License. See the LICENSE file for details.