@cyanheads/gbif-biodiversity-mcp-server

Search GBIF species taxonomy, occurrence records, datasets, and publishers via MCP. STDIO or Streamable HTTP.

13 Tools • 2 Resources

[![Version](https://img.shields.io/badge/Version-0.8.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/gbif-biodiversity-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.4.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/gbif-biodiversity-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/gbif-biodiversity-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.4-blueviolet.svg?style=flat-square)](https://bun.sh/)
[![Install in Claude Desktop](https://img.shields.io/badge/Install_in-Claude_Desktop-D97757?style=for-the-badge&logo=anthropic&logoColor=white)](https://github.com/cyanheads/gbif-biodiversity-mcp-server/releases/latest/download/gbif-biodiversity-mcp-server.mcpb) [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=gbif-biodiversity-mcp-server&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBjeWFuaGVhZHMvZ2JpZi1iaW9kaXZlcnNpdHktbWNwLXNlcnZlciJdfQ==) [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=for-the-badge&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22gbif-biodiversity-mcp-server%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40cyanheads%2Fgbif-biodiversity-mcp-server%22%5D%7D) [![Framework](https://img.shields.io/badge/Built%20on-@cyanheads/mcp--ts--core-67E8F9?style=flat-square)](https://www.npmjs.com/package/@cyanheads/mcp-ts-core)
**Public Hosted Server:** [https://gbif-biodiversity.caseyjhand.com/mcp](https://gbif-biodiversity.caseyjhand.com/mcp)
--- ## Overview Species taxonomy, occurrence records, datasets, and publishers from the Global Biodiversity Information Facility (GBIF). Match names to the backbone taxonomy, search and aggregate 3.9B+ occurrence records with Darwin Core filters, and trace records back to their datasets and publishers. ### Tools | Tool | Description | |:---|:---| | `gbif_match_species` | Match a scientific name to the GBIF backbone, returning its taxon key, confidence, and classification | | `gbif_bulk_match_species` | Match up to 50 scientific names in one call, one result per name in input order | | `gbif_get_species` | Fetch a backbone taxon by key: classification, authorship, synonymy, vernacular name, descendant count | | `gbif_search_species` | Search or browse the backbone by name fragment, rank, or a kingdom, family, or genus name | | `gbif_get_species_classification` | Return a taxon's parent chain, root-first from kingdom to its immediate parent | | `gbif_get_species_children` | List a taxon's direct children, such as the genera in a family or the species in a genus | | `gbif_search_occurrences` | Search 3.9B+ occurrence records by taxon, place, date, basis of record, presence/absence, and IUCN category | | `gbif_count_occurrences` | Count occurrences matching a filter without fetching records | | `gbif_get_occurrence` | Fetch one full Darwin Core occurrence record by key | | `gbif_occurrence_facets` | Aggregate occurrence counts by country, year, dataset, taxon, status, and other dimensions, optionally naming taxon and dataset keys | | `gbif_search_datasets` | Search datasets by keyword, type, publishing country, or publishing or hosting organization | | `gbif_get_dataset` | Fetch full dataset metadata by UUID: citation, contacts, license, DOI, publishing organization key, coverage | | `gbif_search_publishers` | Search GBIF-registered publishing organizations by name or country | ### Resources | Resource | Description | |:---|:---| | `gbif://species/{taxonKey}` | Taxon record from the GBIF backbone: classification, authorship, synonymy status, vernacular name | | `gbif://dataset/{datasetKey}` | Dataset metadata: title, description, citation, license, contacts, coverage, publishing organization key | Both resources are also reachable via `gbif_get_species` and `gbif_get_dataset`; many MCP clients are tool-only and never surface resources. ### Skills | Skill | Description | |:---|:---| | `gbif-biodiversity-guide` | Agent guide: where each key comes from, which region filter selects which records, and the checks before quoting a count, a trend, or an absence. Installed with the Claude Code and Codex plugins and the `.mcpb`, and served over MCP at `skill://gbif-biodiversity-guide/SKILL.md` | ## Capability reference ### `gbif_match_species` tool - One scientific `name` (common names go to `gbif_search_species`), with optional `strict`, a `kingdom` to disambiguate, and an expected `rank` - Returns `taxonKey`, the accepted taxon to pass to the occurrence tools, plus `matchedTaxonKey` when a synonym was resolved, `matchType` (`EXACT`, `FUZZY`, or `HIGHERRANK`: a match at a higher rank, such as a misspelled species falling back to its genus or kingdom, where `taxonKey` covers that whole taxon, or its accepted taxon when the taxon GBIF fell back to is a synonym, and a `notice` names it), `confidence` 0–100 (review below 80; a `HIGHERRANK` match can score above 90), and the classification with a key at each rank - `matchType` `NONE` fails as `no_match`, carrying GBIF's note when it gives one (for several equal matches, set `kingdom`); a blank `kingdom` fails as `invalid_filter` --- ### `gbif_bulk_match_species` tool - Scientific `names` with `strict`; the first 50 are processed in input order. With read-limit clamping enabled (default), extra names are returned in `unprocessedNames`, preserving order and duplicates for the next call - Each result carries `name` and `matchType`, and on a match `taxonKey`, `matchedTaxonKey` (synonyms), and `confidence`; a `HIGHERRANK` row carries a `note` naming the higher taxon GBIF matched and what its `taxonKey` covers: that taxon, or its accepted taxon when it is a synonym. An unmatched name is `NONE`, with GBIF's explanation in `note` when it gives one (such as several equal matches for a name shared across kingdoms); a failed lookup is `ERROR` with `error` and, when classified, `reason`, and neither fails the batch --- ### `gbif_get_species` tool - One backbone `taxonKey`, a non-negative integer (`0` is the backbone kingdom *incertae sedis*); a key not in the backbone fails as `not_found`, one past the 32-bit range as `invalid_filter`, and a fraction or negative key fails at the schema - Returns the classification with rank keys, `authorship`, `vernacularName`, `numDescendants`, `publishedIn`, and `parentKey`; a `taxonomicStatus` of `SYNONYM` comes with `acceptedKey` and `accepted`. GBIF's taxon record carries no occurrence count: use `gbif_count_occurrences` --- ### `gbif_search_species` tool - `q` name fragment (scientific and vernacular), `rank`, `isExtinct`, and a checklist `datasetKey` (omit for the backbone); up to 1,000 per page with integer `limit` and `offset` - Taxa carry the checklist usage `key`, optional source `datasetKey` and mapped backbone `nubKey`, `rank`, `taxonomicStatus`, classification names, `vernacularName` (GBIF's own, else the first English vernacular name containing `q` when `q` is not part of the row's own scientific name, else the first English one), and `numDescendants`. `numOccurrences` appears only when GBIF supplies a positive value; its species search sends a `0` placeholder on every row, so count with `gbif_count_occurrences` on `nubKey` or a `SPECIES_KEY` facet. Enrichment reports `totalCount`, `endOfRecords`, and the `taxonScope` applied. Occurrence queries use `nubKey` when supplied; otherwise resolve the name with `gbif_match_species`. Omitted `datasetKey` selects GBIF Backbone Taxonomy `d7dddbf4-2cf0-4f39-9b2a-bb099caae36c` - `kingdom`, `family`, and `genus` take names, matched exactly as GBIF capitalizes them; the narrowest one scopes, and `kingdom` beside a narrower one disambiguates it. An unknown name fails as `unresolved_taxon_scope`, a genus outside the named family as `conflicting_taxon_scope`, and a scope paired with a non-backbone `datasetKey` matches nothing. A blank filter or a malformed `datasetKey` fails as `invalid_filter` --- ### `gbif_get_species_classification` tool - One `taxonKey`; returns `classification` root-first from kingdom to the immediate parent, each entry with `key`, `rank`, `name`, and `scientificName` - The queried taxon is not included (use `gbif_get_species`); a root taxon returns an empty chain with a notice, an unknown key fails as `not_found`, one past the 32-bit range as `invalid_filter`, and a fraction or negative key fails at the schema --- ### `gbif_get_species_children` tool - One `taxonKey` (a non-negative integer; `0` is the backbone's *incertae sedis* kingdom), up to 1,000 children per page with integer `limit` and `offset`; no filters. A fraction or negative value fails at the schema; an unknown key fails as `not_found`, one past GBIF's 32-bit range as `invalid_filter` - Each child carries `key`, `canonicalName`, `rank`, `taxonomicStatus`, `vernacularName` when GBIF has one, and `numDescendants`; GBIF sends no occurrence count, so use `gbif_count_occurrences` with the child's `key`. GBIF reports no total, so `endOfRecords` (with `truncated` while more remain) is the continuation signal - An empty later page gives offset-reset guidance; only an empty first page for an existing taxon means no direct children --- ### `gbif_search_occurrences` tool - Filters: `taxonKey` (preferred; `scientificName` does not match synonyms), `country`, `publishingCountry`, `stateProvince`, `gadmGid`, a `decimalLatitude`/`decimalLongitude` box or WKT `geometry`, `year`, `month`, `basisOfRecord`, `hasCoordinate`, `isInCluster`, `coordinateUncertaintyInMeters`, `datasetKey`, `catalogNumber`, `institutionCode`, `recordedBy`, `mediaType`, `occurrenceStatus`, `iucnRedListCategory`; up to 300 per page - `gadmGid` is an uppercase GADM GID (`ESP.1_1`) GBIF derives from each record's coordinates, so records without coordinates match none; take one from a `GADM_LEVEL_0_GID`–`GADM_LEVEL_3_GID` facet or `gbif_get_occurrence`'s `gadm` field. `catalogNumber` and `institutionCode` match the whole value case-insensitively, `recordedBy` one collector exactly and case-sensitively (take a spelling from a `RECORDED_BY` facet), and `mediaType` is `StillImage`, `MovingImage`, `Sound`, or `InteractiveResource`. `taxonKey`, `month`, `limit`, and `offset` are integers - All supplied spatial filters intersect: combining `geometry` with latitude or longitude ranges restricts results to their overlap - Records carry `key`, `taxonKey`, `taxonomicStatus`, coordinates, `eventDate` and `eventTime`, `basisOfRecord`, `occurrenceStatus`, `iucnRedListCategory`, `datasetKey`, `institutionCode`, `catalogNumber`, and `issues`; enrichment reports `totalCount`, `endOfRecords`, and the `occurrenceStatus` applied - Search, count and facets echo the same `scope`: effective supplied biological filters, with `occurrenceStatus` always included and pagination controls excluded. `ANY` is echoed but omitted upstream. `taxonKey` and `scientificName` combine with OR. All 11 GBIF basis-of-record values are accepted, including `FOSSIL_SPECIMEN`, `OBSERVATION`, and `UNKNOWN` - Latitude, longitude and coordinate uncertainty are independent optional facts; text renders each supplied value, including zero, in decimal degrees or meters - `offset + limit` caps at 100,001 and GBIF has no cursor; a deeper request fails as `pagination_cap_exceeded`, and a larger match is reached by partitioning on a `DATASET_KEY` facet from `gbif_occurrence_facets`. A rejected filter value fails as `invalid_filter` --- ### `gbif_count_occurrences` tool - Accepts every biological filter listed for `gbif_search_occurrences`, with the same constraints and spatial intersection. `isGeoreferenced` remains an alias for `hasCoordinate`; either alone or both equal works, conflicting values fail as `invalid_filter` - Returns `count`, with the `occurrenceStatus` applied in the enrichment; a count above 100,001 carries a notice naming the partition route, and a rejected filter value fails as `invalid_filter` --- ### `gbif_get_occurrence` tool - One `occurrenceKey` from a search result, a positive integer (a non-positive or fractional key is rejected at the schema); an unknown key fails as `not_found` - The full Darwin Core record: `occurrenceStatus` (`ABSENT` is a survey that found nothing, not a sighting), coordinates, GADM levels 0–3 under `gadm`, dates with `eventTime`, `taxonomicStatus`, `iucnRedListCategory`, institution, collection, and catalog codes, `recordedBy` and `identifiedBy`, `media`, `identifiers`, and `issues` --- ### `gbif_occurrence_facets` tool - `facet` is one of `COUNTRY`, `STATE_PROVINCE`, `YEAR`, `MONTH`, `BASIS_OF_RECORD`, `DATASET_KEY`, `PUBLISHING_COUNTRY`, `KINGDOM_KEY`, `PHYLUM_KEY`, `CLASS_KEY`, `ORDER_KEY`, `FAMILY_KEY`, `GENUS_KEY`, `SPECIES_KEY`, `OCCURRENCE_STATUS`, `IUCN_RED_LIST_CATEGORY`, `GADM_LEVEL_0_GID`–`GADM_LEVEL_3_GID`, `INSTITUTION_CODE`, `MEDIA_TYPE`, `RECORDED_BY`; up to 100 values per page (`facetLimit`, default 10), paged with `facetOffset` - Accepts every biological filter listed for `gbif_search_occurrences`, with the same constraints and spatial intersection. Repeat them on the facet call and each partition search to preserve the population. Returns `totalOccurrences` and `counts` ranked by count; a rejected scope value fails as `invalid_filter` - Each facet request fetches one extra bucket to prove continuation. `truncated`, `shown`, and `cap` appear only when that bucket exists; advance `facetOffset` by the exposed page size. An exactly full final page carries no truncation flag. Empty later pages give offset-reset guidance; an empty first page distinguishes zero matching occurrences from a dimension with no data - `DATASET_KEY` buckets sum to the scope's full total, so it is the facet for partitioning a search too large to page; `YEAR`, `MONTH`, `STATE_PROVINCE`, `SPECIES_KEY`, `INSTITUTION_CODE`, `MEDIA_TYPE`, and `RECORDED_BY` drop records that lack the field, and the GADM levels drop records without coordinates or outside every GADM unit at that level. `MEDIA_TYPE` and `RECORDED_BY` buckets overlap (a record with two media types or two collectors counts in each), so their sums can exceed `totalOccurrences`. `INSTITUTION_CODE` buckets come back lowercased and `institutionCode` takes them unchanged. Pass `facet: OCCURRENCE_STATUS` with `occurrenceStatus: ANY` for the presence/absence split - `includeLabels: true` names each bucket on a `KINGDOM_KEY`–`SPECIES_KEY` or `DATASET_KEY` page: `counts[].label` is the taxon's `canonicalName` or the dataset `title`. It costs one GBIF request per exposed bucket (never the lookahead), at most 8 in flight server-wide, no retries, and one 10 s deadline per page; a bucket whose lookup fails or misses it has no `label` and is counted in `notice`. Other dimensions ignore it, and without it a facet call is one request --- ### `gbif_search_datasets` tool - Filters: `q`, `type` (`OCCURRENCE`, `CHECKLIST`, `METADATA`, `SAMPLING_EVENT`), `publishingCountry`, `publishingOrg`, `hostingOrg`; up to 1,000 per page - Returns `key`, `title`, `type`, `license`, `doi`, `recordCount`, `publishingOrganizationKey` and `publishingOrganizationTitle`, and a 300-character `description` preview, with `descriptionTruncated` flagging a cut (`gbif_get_dataset` has the full text). `publishingOrganizationKey` goes back in as `publishingOrg`. `limit` and `offset` are integers - `publishingOrg` matches the organization whose data it is and is the usual chain from `gbif_search_publishers`; `hostingOrg` matches the organization whose installation serves it, and the two together intersect. Both take the lowercase UUID form; anything else, like any rejected filter value, fails as `invalid_filter` --- ### `gbif_get_dataset` tool - One `datasetKey` UUID and `contactLimit` (default 10, max 100; `0` drops contact detail but keeps `contactsTotal`); a malformed key fails as `invalid_filter`, an unknown one as `not_found` - Returns `description`, `citationText`, `license`, `doi`, `publishingOrganizationKey` (goes into `gbif_search_datasets` as `publishingOrg`; the detail record carries no organization name), `recordCount`, `numConstituents`, `contacts` with `contactsTotal` and `contactsReturned`, `temporalCoverages`, and `geographicCoverages` --- ### `gbif_search_publishers` tool - `q` name fragment and `country` (alpha-2 or alpha-3, any case); up to 1,000 per page with integer `limit` and `offset` - Returns organization `key`, `title`, `country`, and `city`; the key chains into `gbif_search_datasets` as `publishingOrg` - A blank `q`, an empty `country`, or a country GBIF cannot parse fails as `invalid_filter` --- ### `gbif://species/{taxonKey}` resource - Taxon record as `application/json`: classification names, `authorship`, `vernacularName`, `taxonomicStatus` with `acceptedKey` and `accepted`, `numDescendants` - `taxonKey` is a whole signed decimal integer within the signed 32-bit range; `0` is the backbone kingdom *incertae sedis*. The segment is URI-decoded once and surrounding whitespace is trimmed; leading `+`, zero padding and their percent-encoded equivalents are accepted. An unknown key fails as `not_found`; malformed escapes, suffixes, fractions, exponents, hexadecimal forms, embedded whitespace and out-of-range values fail as `invalid_filter` before a request. Use a backbone key from `gbif_match_species` or a search result's `nubKey`, resolving its name when that mapping is absent --- ### `gbif://dataset/{datasetKey}` resource - The `gbif_get_dataset` record as `application/json`, including `publishingOrganizationKey`, with contacts fixed at 10 - `datasetKey` comes from `gbif_search_datasets` or an occurrence record; an unknown key fails as `not_found`, a malformed one as `invalid_filter` ## Features Built on [`@cyanheads/mcp-ts-core`](https://github.com/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. GBIF-specific: - GBIF API v1 species, occurrence, dataset, and organization endpoints, keyless, with an identifying `User-Agent` on every request - Synonym-safe keys: `gbif_match_species` resolves a synonym to its accepted taxon, and the occurrence tools filter on that `taxonKey` - Sightings by default: the search, count, and facet tools default `occurrenceStatus` to `PRESENT`, excluding absence records. Dataset `recordCount` spans every status, so it can exceed a `gbif_count_occurrences` total for the same key - Strict country codes: `country` (where a record was observed) and `publishingCountry` (the publisher's country) on the occurrence tools, and `publishingCountry` on `gbif_search_datasets`, accept only uppercase ISO 3166-1 alpha-2, because other forms silently match nothing there. `stateProvince` matches verbatim and case-sensitively, so take values from a `STATE_PROVINCE` facet - Blank filters rejected: GBIF reads an empty parameter as no filter, so an optional filter sent blank or whitespace-only fails as `invalid_filter` instead of widening the query. The exception is a blank `country` or `publishingCountry` on the occurrence tools, or a blank `publishingCountry` on `gbif_search_datasets`, which fails earlier at the uppercase pattern as `-32602 invalid_arguments`. Omit a field to leave it off Agent-friendly output: - Scope echo: enrichment reports `totalCount`, `endOfRecords`, the `occurrenceStatus` applied, and the resolved `taxonScope`, with notices on empty results, over-cap totals, and a `stateProvince`, `gadmGid`, or `recordedBy` that matched nothing - Graceful partial failure: `gbif_bulk_match_species` returns a `NONE` or `ERROR` row per name instead of failing the batch - Typed errors: `invalid_filter`, `not_found`, `no_match`, `unresolved_taxon_scope`, `conflicting_taxon_scope`, and `pagination_cap_exceeded`, each with a recovery hint - Sparse fields omitted rather than null-filled: `extinct` appears only on taxa GBIF explicitly flags - Dataset metadata preserves temporal ranges, single dates, and verbatim periods such as `1975-current`; unsupported coverage entries are disclosed. An unavailable supplementary record count carries `recordCountNotice` while retaining metadata - Publisher descriptions retain paragraph breaks and decode numeric character references once; Markdown output renders publisher text literally ## Getting started ### Public Hosted Instance A public instance is available at `https://gbif-biodiversity.caseyjhand.com/mcp` — no installation required. Point any MCP client at it via Streamable HTTP: ```json { "mcpServers": { "gbif-biodiversity-mcp-server": { "type": "streamable-http", "url": "https://gbif-biodiversity.caseyjhand.com/mcp" } } } ``` ### Self-Hosted / Local Add the following to your MCP client configuration file. ```json { "mcpServers": { "gbif-biodiversity-mcp-server": { "type": "stdio", "command": "bunx", "args": ["@cyanheads/gbif-biodiversity-mcp-server@latest"], "env": { "MCP_TRANSPORT_TYPE": "stdio", "MCP_LOG_LEVEL": "info" } } } } ``` Or with npx (no Bun required): ```json { "mcpServers": { "gbif-biodiversity-mcp-server": { "type": "stdio", "command": "npx", "args": ["-y", "@cyanheads/gbif-biodiversity-mcp-server@latest"], "env": { "MCP_TRANSPORT_TYPE": "stdio", "MCP_LOG_LEVEL": "info" } } } } ``` Or with Docker: ```json { "mcpServers": { "gbif-biodiversity-mcp-server": { "type": "stdio", "command": "docker", "args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/gbif-biodiversity-mcp-server:latest"] } } } ``` For Streamable HTTP, set the transport and start the server: ```sh MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http # Server listens at http://localhost:3010/mcp ``` ### Prerequisites - [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+). - No credentials: the GBIF endpoints this server calls are public. ### Installation 1. **Clone the repository:** ```sh git clone https://github.com/cyanheads/gbif-biodiversity-mcp-server.git ``` 2. **Navigate into the directory:** ```sh cd gbif-biodiversity-mcp-server ``` 3. **Install dependencies:** ```sh bun install ``` 4. **Configure environment:** ```sh cp .env.example .env # optionally set GBIF_USER_AGENT to a contact URL or email ``` ## Configuration | Variable | Description | Default | |:---|:---|:---| | `GBIF_BASE_URL` | GBIF API base URL. | `https://api.gbif.org/v1` | | `GBIF_CLAMP_READ_LIMITS` | Clamp oversized whole-number read limits to the documented caps and disclose requested/applied values. Bulk matching processes the first 50 names and returns the remainder in `unprocessedNames`. Set `false` to advertise and enforce rejecting maxima instead. Strict boolean parsing rejects malformed values. | `true` | | `GBIF_REQUEST_TIMEOUT_MS` | Per-request HTTP timeout, in ms. | `10000` | | `GBIF_USER_AGENT` | `User-Agent` sent on every GBIF request. GBIF asks integrators to identify themselves with a contact URL or email. | server name, version, and repository URL | | `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` | | `MCP_HTTP_PORT` | HTTP server port. | `3010` | | `MCP_SESSION_MODE` | HTTP session mode: `stateless`, `stateful`, or `auto`. `src/index.ts` declares `stateless`; set this to override it. | `stateless` | | `MCP_AUTH_MODE` | Authentication: `none`, `jwt`, or `oauth`. | `none` | | `MCP_LOG_LEVEL` | Log level (`debug`, `info`, `warning`, `error`, etc.). | `info` | | `LOG_TOOL_FAILURE_PAYLOADS` | Log each failed tool call's arguments and result, redacted by key name and capped at `LOG_TOOL_FAILURE_PAYLOAD_MAX_BYTES` (default `16384`). A secret inside a free-form value is not redacted. | `false` | | `LOGS_DIR` | Directory for log files (Node.js only). | `/logs` | | `STORAGE_PROVIDER_TYPE` | Storage backend: `in-memory`, `filesystem`, `supabase`, `cloudflare-kv/r2/d1`. | `in-memory` | | `OTEL_ENABLED` | Enable [OpenTelemetry](https://github.com/cyanheads/mcp-ts-core/tree/main/docs/telemetry). | `false` | See [`.env.example`](./.env.example) for the full list of optional overrides. ## Running the server ### Local development - **Build and run the production version**: ```sh # One-time build bun run rebuild # Run the built server bun run start:http # or bun run start:stdio ``` - **Run checks and tests**: ```sh bun run devcheck # Lints, formats, type-checks, and more bun run test # Runs the test suite ``` ### Docker ```sh docker build -t gbif-biodiversity-mcp-server . docker run --rm -p 3010:3010 gbif-biodiversity-mcp-server ``` The Dockerfile defaults to HTTP transport, stateless session mode, and logs to `/var/log/gbif-biodiversity-mcp-server`. OpenTelemetry peer dependencies are installed by default; build with `--build-arg OTEL_ENABLED=false` to omit them. ## Project structure | Directory | Purpose | |:---|:---| | `src/mcp-server/tools` | Tool definitions (`*.tool.ts`) and shared tool utilities. Thirteen tools across species taxonomy, occurrences, datasets, and publishers. | | `src/mcp-server/resources` | Resource definitions. Species and dataset resources. | | `src/services/gbif` | GBIF API service layer: HTTP client with retry, timeouts, and error mapping, plus raw response types. | | `src/config` | Server-specific environment variable parsing and validation with Zod. | | `tests/` | Unit and integration tests, mirroring the `src/` structure. | ## Development guide See [`CLAUDE.md`](./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: ```sh bun run devcheck bun run test ``` ## License This project is licensed under the Apache 2.0 License. See the [LICENSE](./LICENSE) file for details.