docs: add setup guide and test coverage
This commit is contained in:
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 <YOUR_GITEA_URL>/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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user