diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0261af4 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Optional: local Firecrawl endpoint for targeted botanical source search. +# Example: http://localhost:3002 or http://firecrawl:3002 +FIRECRAWL_API_URL= + +# Optional if your Firecrawl instance requires it. +FIRECRAWL_API_KEY= + +# Optional: comma-separated allow-list used by search_botanical_sources. +BOTANICAL_ALLOWED_DOMAINS=gbif.org,powo.science.kew.org,missouribotanicalgarden.org,tela-botanica.org,inpn.mnhn.fr,rhs.org.uk,edu,wikimedia.org,wikipedia.org + +# Request timeout in seconds. +OPENHERBARIUM_TIMEOUT=20 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..03c5684 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenHerbarium MCP contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..30fad68 --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +# OpenHerbarium MCP + +**Botanical Knowledge MCP Server** — a Model Context Protocol server that gives AI agents structured, sourced botanical data from free/open sources. + +OpenHerbarium MCP is designed for a downstream agent such as **Hermes Agent Botaniste**. It is deliberately a knowledge-access layer only: it fetches, normalizes and cites botanical evidence, then lets the client agent reason, write, format and publish. + +## What this MCP does + +It exposes MCP tools for: + +- plant lookup by common or scientific name; +- taxonomic information; +- species information such as distribution, habitat and botanical descriptions; +- horticultural care-source discovery; +- image-source discovery with licence and author metadata; +- targeted botanical source search through a local Firecrawl instance when configured. + +Every returned data point is wrapped with provenance: + +```json +{ + "value": "Marantaceae", + "source": "GBIF Backbone Taxonomy", + "url": "https://www.gbif.org/species/8184122", + "retrieved_at": "2026-08-02T12:00:00+00:00", + "confidence": "high" +} +``` + +## What this MCP does **not** do + +OpenHerbarium MCP never: + +- creates final Markdown plant sheets; +- writes to Outline; +- manages Outline collections; +- creates care calendars; +- decides final horticultural recommendations; +- replaces the reasoning/presentation layer of Hermes Agent. + +## MCP tools + +### `search_plant(name: str)` + +Returns: + +- accepted scientific name; +- canonical name; +- common/vernacular names when available; +- synonyms; +- family, genus, species and rank; +- external identifiers such as GBIF keys and GBIF species URL; +- best-effort Kew/POWO results when accessible. + +### `get_taxonomy(name: str)` + +Returns sourced taxonomy: + +- kingdom; +- phylum/class/order when available; +- family; +- genus; +- species; +- scientific name; +- botanical authorship; +- taxonomic status; +- synonyms. + +Priority sources: + +- GBIF Backbone Taxonomy; +- Plants of the World Online, Kew, best-effort. + +### `get_species_information(name: str)` + +Returns raw evidence for: + +- botanical descriptions; +- known distribution; +- natural habitat where available; +- additional source-search records when Firecrawl is configured. + +### `get_care_sources(name: str)` + +Returns raw source candidates for: + +- light; +- temperature; +- humidity; +- watering; +- substrate; +- fertilisation; +- repotting. + +It does **not** transform those sources into final care advice. + +### `get_image_sources(name: str, limit: int = 10)` + +Uses Wikimedia Commons to return: + +- image URL; +- licence; +- author; +- source page. + +### `search_botanical_sources(query: str, limit: int = 5)` + +Uses a local Firecrawl instance if `FIRECRAWL_API_URL` is configured. If not configured, the tool returns source-constrained suggested queries rather than pretending to have scraped content. + +## Sources + +Implemented/targeted sources: + +- [GBIF](https://www.gbif.org/) +- [Plants of the World Online, Kew](https://powo.science.kew.org/) best-effort connector +- [Wikimedia Commons](https://commons.wikimedia.org/) +- [Royal Horticultural Society](https://www.rhs.org.uk/) source candidates / Firecrawl search +- [Tela Botanica](https://www.tela-botanica.org/) source candidates / Firecrawl search +- public university horticultural resources via targeted Firecrawl search + +No paid API is required. + +## Installation + +### Requirements + +- Python 3.11+ +- `uv` recommended, or any Python package installer capable of creating a virtual environment + +### Local install with `uv` + +```bash +git clone /openherbarium-mcp.git +cd openherbarium-mcp +uv sync --extra dev +``` + +Run tests: + +```bash +uv run pytest +``` + +Run the MCP server over stdio: + +```bash +uv run openherbarium-mcp +``` + +## Hermes Agent configuration + +Add the server to `~/.hermes/config.yaml`: + +```yaml +mcp_servers: + openherbarium: + command: "uv" + args: + - "--directory" + - "/absolute/path/to/openherbarium-mcp" + - "run" + - "openherbarium-mcp" + timeout: 120 + connect_timeout: 60 +``` + +Restart Hermes Agent. Tools will be exposed with names similar to: + +- `mcp_openherbarium_search_plant` +- `mcp_openherbarium_get_taxonomy` +- `mcp_openherbarium_get_species_information` +- `mcp_openherbarium_get_care_sources` +- `mcp_openherbarium_get_image_sources` +- `mcp_openherbarium_search_botanical_sources` + +## Firecrawl configuration + +Copy `.env.example` to `.env` and set: + +```bash +FIRECRAWL_API_URL=http://localhost:3002 +FIRECRAWL_API_KEY= +``` + +If Firecrawl is not configured, source-search tools return explicit suggested queries and a `not_configured` status. + +## Development + +```bash +uv sync --extra dev +uv run pytest +``` + +Project layout: + +```text +openherbarium-mcp/ +├── src/ +│ └── openherbarium_mcp/ +│ ├── server.py +│ ├── gbif.py +│ ├── kew.py +│ ├── rhs.py +│ ├── tela_botanica.py +│ ├── wikimedia.py +│ └── firecrawl.py +├── tests/ +├── README.md +├── pyproject.toml +├── LICENSE +└── .env.example +``` + +## Reliability model + +The server distinguishes between: + +- **high confidence**: exact/high-confidence API matches, mostly GBIF taxonomic matches; +- **medium confidence**: sourced API records that still require interpretation; +- **low confidence**: source candidates or search results that must be manually/agent verified. + +OpenHerbarium MCP prefers returning `status: unavailable`, `status: not_configured` or empty records over inventing missing data. + +## License + +MIT diff --git a/tests/test_gbif.py b/tests/test_gbif.py new file mode 100644 index 0000000..8d53545 --- /dev/null +++ b/tests/test_gbif.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import httpx +import pytest + +from openherbarium_mcp.gbif import GBIFClient +from openherbarium_mcp.http import BotanicalHTTPClient + + +@pytest.mark.asyncio +async def test_gbif_match_wraps_identity_with_evidence() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/species/match": + return httpx.Response( + 200, + json={ + "usageKey": 1, + "acceptedUsageKey": 2, + "scientificName": "Calathea warscewiczii (L.Mathieu ex Planch.) Planch. & Linden", + "canonicalName": "Calathea warscewiczii", + "species": "Goeppertia warscewiczii", + "family": "Marantaceae", + "genus": "Goeppertia", + "rank": "SPECIES", + "status": "SYNONYM", + "confidence": 98, + }, + ) + if request.url.path in {"/v1/species/2/vernacularNames", "/v1/species/2/synonyms"}: + return httpx.Response(200, json={"results": []}) + raise AssertionError(f"unexpected request path: {request.url.path}") + + transport = httpx.MockTransport(handler) + async with BotanicalHTTPClient(transport=transport) as http: + result = await GBIFClient(http).match_plant("Calathea warscewiczii") + + assert result["usage_key"] == 2 + assert result["accepted_scientific_name"]["value"] == "Goeppertia warscewiczii" + assert result["accepted_scientific_name"]["source"] == "GBIF Backbone Taxonomy" + assert result["family"]["value"] == "Marantaceae" + assert result["external_ids"]["value"]["gbif_accepted_usage_key"] == 2 diff --git a/tests/test_server_contract.py b/tests/test_server_contract.py new file mode 100644 index 0000000..91dd8a6 --- /dev/null +++ b/tests/test_server_contract.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from openherbarium_mcp import __version__ +from openherbarium_mcp.server import mcp + + +def test_package_version() -> None: + assert __version__ == "0.1.0" + + +def test_fastmcp_server_exists() -> None: + assert mcp.name == "OpenHerbarium MCP" diff --git a/tests/test_wikimedia.py b/tests/test_wikimedia.py new file mode 100644 index 0000000..b0920f9 --- /dev/null +++ b/tests/test_wikimedia.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import httpx +import pytest + +from openherbarium_mcp.http import BotanicalHTTPClient +from openherbarium_mcp.wikimedia import WikimediaClient + + +@pytest.mark.asyncio +async def test_wikimedia_image_sources_include_license_author_and_url() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/w/api.php" + return httpx.Response( + 200, + json={ + "query": { + "pages": { + "123": { + "pageid": 123, + "title": "File:Plant.jpg", + "imageinfo": [ + { + "url": "https://upload.wikimedia.org/Plant.jpg", + "descriptionurl": "https://commons.wikimedia.org/wiki/File:Plant.jpg", + "user": "Example author", + "extmetadata": { + "LicenseShortName": {"value": "CC BY-SA 4.0"}, + "Artist": {"value": "Example artist"}, + }, + } + ], + } + } + } + }, + ) + + transport = httpx.MockTransport(handler) + async with BotanicalHTTPClient(transport=transport) as http: + records = await WikimediaClient(http).image_sources("Goeppertia warscewiczii") + + assert records[0]["image_url"]["value"].endswith("Plant.jpg") + assert records[0]["license"]["value"] == "CC BY-SA 4.0" + assert records[0]["author"]["value"] == "Example artist"