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