feat: add OpenHerbarium MCP server core

This commit is contained in:
Hermes Agent
2026-08-02 17:08:56 +02:00
commit 974cbe6c68
14 changed files with 1605 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
"""OpenHerbarium MCP: sourced botanical knowledge access for MCP clients."""
__all__ = ["__version__"]
__version__ = "0.1.0"
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import os
from dataclasses import dataclass, field
from dotenv import load_dotenv
DEFAULT_ALLOWED_DOMAINS = [
"gbif.org",
"powo.science.kew.org",
"missouribotanicalgarden.org",
"tela-botanica.org",
"inpn.mnhn.fr",
"rhs.org.uk",
"edu",
"wikimedia.org",
"wikipedia.org",
]
@dataclass(frozen=True)
class Settings:
timeout: float = 20.0
firecrawl_api_url: str | None = None
firecrawl_api_key: str | None = None
allowed_domains: list[str] = field(default_factory=lambda: DEFAULT_ALLOWED_DOMAINS.copy())
def load_settings() -> Settings:
load_dotenv()
domains_raw = os.getenv("BOTANICAL_ALLOWED_DOMAINS")
domains = (
[part.strip() for part in domains_raw.split(",") if part.strip()]
if domains_raw
else DEFAULT_ALLOWED_DOMAINS.copy()
)
timeout_raw = os.getenv("OPENHERBARIUM_TIMEOUT", "20")
try:
timeout = float(timeout_raw)
except ValueError:
timeout = 20.0
return Settings(
timeout=timeout,
firecrawl_api_url=os.getenv("FIRECRAWL_API_URL") or None,
firecrawl_api_key=os.getenv("FIRECRAWL_API_KEY") or None,
allowed_domains=domains,
)
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Any
import httpx
from .config import Settings
from .http import BotanicalHTTPClient
from .models import evidence, utc_now_iso
FIRECRAWL_SOURCE = "Local Firecrawl"
class FirecrawlClient:
def __init__(self, http: BotanicalHTTPClient, settings: Settings):
self.http = http
self.settings = settings
async def search(self, query: str, limit: int = 5, domains: list[str] | None = None) -> dict[str, Any]:
if not self.settings.firecrawl_api_url:
return {
"status": "not_configured",
"source": FIRECRAWL_SOURCE,
"retrieved_at": utc_now_iso(),
"reason": "Set FIRECRAWL_API_URL to enable targeted source search.",
"suggested_queries": self._suggested_web_queries(query, domains),
"records": [],
}
base = self.settings.firecrawl_api_url.rstrip("/")
headers = {}
if self.settings.firecrawl_api_key:
headers["Authorization"] = f"Bearer {self.settings.firecrawl_api_key}"
allowed = domains or self.settings.allowed_domains
constrained_query = query if not allowed else f"{query} ({' OR '.join('site:' + d for d in allowed[:8])})"
payload = {"query": constrained_query, "limit": min(max(limit, 1), 10)}
endpoints = [f"{base}/v1/search", f"{base}/search"]
last_error: str | None = None
for endpoint in endpoints:
try:
data = await self.http.post_json(endpoint, payload, headers=headers)
return self._normalize(data, query, endpoint)
except httpx.HTTPError as exc:
last_error = str(exc)
return {
"status": "error",
"source": FIRECRAWL_SOURCE,
"retrieved_at": utc_now_iso(),
"reason": last_error or "Unknown Firecrawl error",
"records": [],
}
def _normalize(self, data: dict[str, Any], query: str, endpoint: str) -> dict[str, Any]:
rows = data.get("data") or data.get("results") or []
if isinstance(rows, dict):
rows = rows.get("results", [])
records = []
for row in rows if isinstance(rows, list) else []:
url = row.get("url") or row.get("sourceURL")
records.append(
{
"title": evidence(row.get("title"), source=FIRECRAWL_SOURCE, url=url, confidence="medium"),
"url": evidence(url, source=FIRECRAWL_SOURCE, url=url, confidence="medium"),
"snippet": evidence(row.get("description") or row.get("markdown") or row.get("content"), source=FIRECRAWL_SOURCE, url=url, confidence="medium", raw=row),
}
)
return {
"status": "ok",
"query": query,
"source": FIRECRAWL_SOURCE,
"firecrawl_endpoint": endpoint,
"retrieved_at": utc_now_iso(),
"records": records,
"raw": data,
}
def _suggested_web_queries(self, query: str, domains: list[str] | None) -> list[str]:
allowed = domains or self.settings.allowed_domains
return [f"{query} site:{domain}" for domain in allowed[:8]]
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from typing import Any
from .http import BotanicalHTTPClient
from .models import evidence
GBIF_BASE = "https://api.gbif.org/v1"
GBIF_SOURCE = "GBIF Backbone Taxonomy"
class GBIFClient:
def __init__(self, http: BotanicalHTTPClient):
self.http = http
async def match_plant(self, name: str) -> dict[str, Any]:
url = f"{GBIF_BASE}/species/match"
data = await self.http.get_json(url, {"name": name})
confidence = _gbif_confidence(data.get("confidence"))
key = data.get("acceptedUsageKey") or data.get("usageKey") or data.get("speciesKey")
external_ids = {"gbif_usage_key": data.get("usageKey")}
if data.get("acceptedUsageKey"):
external_ids["gbif_accepted_usage_key"] = data.get("acceptedUsageKey")
if key:
external_ids["gbif_species_url"] = f"https://www.gbif.org/species/{key}"
result = {
"accepted_scientific_name": evidence(
data.get("species") or data.get("scientificName") or data.get("canonicalName"),
source=GBIF_SOURCE,
url=external_ids.get("gbif_species_url"),
confidence=confidence,
raw={k: data.get(k) for k in ["matchType", "status", "confidence", "rank"]},
),
"canonical_name": evidence(data.get("canonicalName"), source=GBIF_SOURCE, url=external_ids.get("gbif_species_url"), confidence=confidence),
"family": evidence(data.get("family"), source=GBIF_SOURCE, url=external_ids.get("gbif_species_url"), confidence=confidence),
"genus": evidence(data.get("genus"), source=GBIF_SOURCE, url=external_ids.get("gbif_species_url"), confidence=confidence),
"species": evidence(data.get("species"), source=GBIF_SOURCE, url=external_ids.get("gbif_species_url"), confidence=confidence),
"rank": evidence(data.get("rank"), source=GBIF_SOURCE, url=external_ids.get("gbif_species_url"), confidence=confidence),
"status": evidence(data.get("status"), source=GBIF_SOURCE, url=external_ids.get("gbif_species_url"), confidence=confidence),
"external_ids": evidence(external_ids, source=GBIF_SOURCE, url=external_ids.get("gbif_species_url"), confidence=confidence),
"raw_match": data,
}
if key:
result["usage_key"] = key
result["vernacular_names"] = await self.vernacular_names(int(key))
result["synonyms"] = await self.synonyms(int(key))
return result
async def taxonomy(self, name: str) -> dict[str, Any]:
match = await self.match_plant(name)
key = match.get("usage_key")
if not key:
return match
species = await self.http.get_json(f"{GBIF_BASE}/species/{key}")
url = f"https://www.gbif.org/species/{key}"
confidence = _gbif_confidence(match.get("raw_match", {}).get("confidence"))
return {
"kingdom": evidence(species.get("kingdom"), source=GBIF_SOURCE, url=url, confidence=confidence),
"phylum": evidence(species.get("phylum"), source=GBIF_SOURCE, url=url, confidence=confidence),
"class": evidence(species.get("class"), source=GBIF_SOURCE, url=url, confidence=confidence),
"order": evidence(species.get("order"), source=GBIF_SOURCE, url=url, confidence=confidence),
"family": evidence(species.get("family"), source=GBIF_SOURCE, url=url, confidence=confidence),
"genus": evidence(species.get("genus"), source=GBIF_SOURCE, url=url, confidence=confidence),
"species": evidence(species.get("species"), source=GBIF_SOURCE, url=url, confidence=confidence),
"scientific_name": evidence(species.get("scientificName"), source=GBIF_SOURCE, url=url, confidence=confidence),
"canonical_name": evidence(species.get("canonicalName"), source=GBIF_SOURCE, url=url, confidence=confidence),
"authorship": evidence(species.get("authorship"), source=GBIF_SOURCE, url=url, confidence=confidence),
"taxonomic_status": evidence(species.get("taxonomicStatus"), source=GBIF_SOURCE, url=url, confidence=confidence),
"synonyms": await self.synonyms(int(key)),
"external_ids": match.get("external_ids"),
}
async def species_information(self, name: str) -> dict[str, Any]:
match = await self.match_plant(name)
key = match.get("usage_key")
if not key:
return {"match": match, "descriptions": [], "distributions": [], "habitats": []}
url = f"https://www.gbif.org/species/{key}"
descriptions = await self._paged(f"{GBIF_BASE}/species/{key}/descriptions")
distributions = await self._paged(f"{GBIF_BASE}/species/{key}/distributions")
habitats = await self._paged(f"{GBIF_BASE}/species/{key}/habitats")
return {
"match": match,
"description_botanique": [
evidence(_description_text(item), source=item.get("source") or GBIF_SOURCE, url=url, confidence="medium", raw=item)
for item in descriptions
],
"distribution_connue": [
evidence(_distribution_value(item), source=item.get("source") or GBIF_SOURCE, url=url, confidence="medium", raw=item)
for item in distributions
],
"habitat_naturel": [
evidence(item.get("habitat") or item, source=item.get("source") or GBIF_SOURCE, url=url, confidence="medium", raw=item)
for item in habitats
],
}
async def vernacular_names(self, usage_key: int) -> list[dict[str, Any]]:
items = await self._paged(f"{GBIF_BASE}/species/{usage_key}/vernacularNames")
return [
evidence(
item.get("vernacularName"),
source=item.get("source") or GBIF_SOURCE,
url=f"https://www.gbif.org/species/{usage_key}",
confidence="medium",
raw=item,
)
for item in items
if item.get("vernacularName")
]
async def synonyms(self, usage_key: int) -> list[dict[str, Any]]:
items = await self._paged(f"{GBIF_BASE}/species/{usage_key}/synonyms")
return [
evidence(
item.get("scientificName") or item.get("canonicalName"),
source=GBIF_SOURCE,
url=f"https://www.gbif.org/species/{item.get('key', usage_key)}",
confidence="medium",
raw=item,
)
for item in items
if item.get("scientificName") or item.get("canonicalName")
]
async def _paged(self, url: str, limit: int = 50) -> list[dict[str, Any]]:
data = await self.http.get_json(url, {"limit": limit})
results = data.get("results", [])
return results if isinstance(results, list) else []
def _gbif_confidence(score: Any) -> str:
try:
value = int(score)
except (TypeError, ValueError):
return "medium"
if value >= 95:
return "high"
if value >= 75:
return "medium"
return "low"
def _description_text(item: dict[str, Any]) -> str | None:
return item.get("description") or item.get("content") or item.get("value")
def _distribution_value(item: dict[str, Any]) -> dict[str, Any]:
keys = ["locality", "country", "area", "status", "threatStatus", "establishmentMeans"]
value = {key: item.get(key) for key in keys if item.get(key) is not None}
return value or item
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
import httpx
USER_AGENT = "OpenHerbarium-MCP/0.1 (+https://example.invalid/openherbarium-mcp)"
class BotanicalHTTPClient:
"""Small async HTTP wrapper with conservative defaults for public botanical APIs."""
def __init__(self, timeout: float = 20.0, transport: httpx.AsyncBaseTransport | None = None):
self.timeout = timeout
self.transport = transport
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> "BotanicalHTTPClient":
self._client = httpx.AsyncClient(
timeout=self.timeout,
follow_redirects=True,
transport=self.transport,
headers={"User-Agent": USER_AGENT, "Accept": "application/json,text/html;q=0.9,*/*;q=0.8"},
)
return self
async def __aexit__(self, exc_type, exc, tb) -> None: # type: ignore[no-untyped-def]
if self._client:
await self._client.aclose()
@property
def client(self) -> httpx.AsyncClient:
if self._client is None:
raise RuntimeError("BotanicalHTTPClient must be used as an async context manager")
return self._client
async def get_json(self, url: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
response = await self.client.get(url, params=params)
response.raise_for_status()
data = response.json()
return data if isinstance(data, dict) else {"results": data}
async def post_json(self, url: str, payload: Mapping[str, Any], headers: Mapping[str, str] | None = None) -> dict[str, Any]:
response = await self.client.post(url, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
return data if isinstance(data, dict) else {"results": data}
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from typing import Any
import httpx
from .http import BotanicalHTTPClient
from .models import evidence
POWO_BASE = "https://powo.science.kew.org"
POWO_SOURCE = "Plants of the World Online, Kew"
class KewClient:
"""Best-effort POWO connector.
POWO is a public website. Its JSON API may reject automated requests depending on its
anti-bot policy. The connector reports that status instead of fabricating data.
"""
def __init__(self, http: BotanicalHTTPClient):
self.http = http
async def search(self, name: str) -> dict[str, Any]:
api_url = f"{POWO_BASE}/api/2/search"
try:
data = await self.http.get_json(api_url, {"q": name})
except httpx.HTTPStatusError as exc:
if exc.response.status_code in {401, 403, 429}:
return {
"status": "unavailable",
"source": POWO_SOURCE,
"url": f"{POWO_BASE}/results?q={name}",
"reason": f"POWO API returned HTTP {exc.response.status_code}; consult the public page manually or through an allowed crawler.",
"records": [],
}
raise
results = data.get("results") or data.get("docs") or []
if not isinstance(results, list):
results = []
return {
"status": "ok",
"source": POWO_SOURCE,
"url": f"{POWO_BASE}/results?q={name}",
"records": [self._normalize_record(item) for item in results[:10]],
"raw": data,
}
def _normalize_record(self, item: dict[str, Any]) -> dict[str, Any]:
fqid = item.get("fqId") or item.get("id") or item.get("accepted")
url = f"{POWO_BASE}/taxon/{fqid}" if fqid else POWO_BASE
return {
"scientific_name": evidence(item.get("name") or item.get("scientificName"), source=POWO_SOURCE, url=url, confidence="medium", raw=item),
"family": evidence(item.get("family"), source=POWO_SOURCE, url=url, confidence="medium"),
"rank": evidence(item.get("rank"), source=POWO_SOURCE, url=url, confidence="medium"),
"fqid": evidence(fqid, source=POWO_SOURCE, url=url, confidence="medium"),
}
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
Confidence = str
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def evidence(
value: Any,
*,
source: str,
url: str | None = None,
confidence: Confidence = "medium",
retrieved_at: str | None = None,
raw: Any | None = None,
) -> dict[str, Any]:
"""Wrap a data value with provenance.
The MCP deliberately returns evidence records rather than editorial recommendations.
Hermes or another client remains responsible for interpreting and presenting the data.
"""
record: dict[str, Any] = {
"value": value,
"source": source,
"url": url,
"retrieved_at": retrieved_at or utc_now_iso(),
"confidence": confidence,
}
if raw is not None:
record["raw"] = raw
return record
def empty_result(query: str, tool: str, reason: str) -> dict[str, Any]:
return {
"query": query,
"tool": tool,
"retrieved_at": utc_now_iso(),
"status": "no_data",
"reason": reason,
"records": [],
}
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
from urllib.parse import quote_plus
from .models import evidence, utc_now_iso
RHS_SOURCE = "Royal Horticultural Society"
CARE_TOPICS = ["light", "temperature", "humidity", "watering", "substrate", "fertilisation", "repotting"]
def rhs_care_source_candidates(name: str) -> dict[str, Any]:
"""Return RHS search/source candidates without turning them into care recommendations."""
query = quote_plus(name)
base_url = f"https://www.rhs.org.uk/search?query={query}"
return {
"status": "source_candidates",
"source": RHS_SOURCE,
"retrieved_at": utc_now_iso(),
"records": [
{
"topic": topic,
"value": evidence(
f"RHS search candidate for {topic}; fetch and verify before use.",
source=RHS_SOURCE,
url=base_url,
confidence="low",
),
}
for topic in CARE_TOPICS
],
}
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
from typing import Any
from mcp.server.mcpserver import MCPServer
from .config import load_settings
from .firecrawl import FirecrawlClient
from .gbif import GBIFClient
from .http import BotanicalHTTPClient
from .kew import KewClient
from .rhs import rhs_care_source_candidates
from .tela_botanica import tela_source_candidates
from .wikimedia import WikimediaClient
mcp = MCPServer("OpenHerbarium MCP")
async def _with_clients() -> tuple[BotanicalHTTPClient, GBIFClient, KewClient, WikimediaClient, FirecrawlClient]:
# Kept for type hint clarity; tools instantiate the context manager directly.
raise RuntimeError("Use 'async with BotanicalHTTPClient' inside each tool")
@mcp.tool()
async def search_plant(name: str) -> dict[str, Any]:
"""Search a plant by common or scientific name and return sourced identity data.
Returns accepted scientific name, common names, synonyms, family, genus, species,
and external identifiers. This tool does not generate plant profile prose.
"""
settings = load_settings()
async with BotanicalHTTPClient(timeout=settings.timeout) as http:
gbif = GBIFClient(http)
kew = KewClient(http)
gbif_result = await gbif.match_plant(name)
kew_result = await kew.search(name)
return {
"query": name,
"tool": "search_plant",
"gbif": gbif_result,
"kew": kew_result,
}
@mcp.tool()
async def get_taxonomy(name: str) -> dict[str, Any]:
"""Return sourced taxonomy: kingdom, family, genus, species, authorship and synonyms."""
settings = load_settings()
async with BotanicalHTTPClient(timeout=settings.timeout) as http:
gbif = GBIFClient(http)
kew = KewClient(http)
return {
"query": name,
"tool": "get_taxonomy",
"gbif": await gbif.taxonomy(name),
"kew": await kew.search(name),
}
@mcp.tool()
async def get_species_information(name: str) -> dict[str, Any]:
"""Return raw sourced species information: geography, habitat, distribution and descriptions."""
settings = load_settings()
async with BotanicalHTTPClient(timeout=settings.timeout) as http:
gbif = GBIFClient(http)
firecrawl = FirecrawlClient(http, settings)
gbif_info = await gbif.species_information(name)
botanical_search = await firecrawl.search(
f"{name} origin habitat distribution botanical description",
limit=5,
domains=["gbif.org", "powo.science.kew.org", "missouribotanicalgarden.org", "tela-botanica.org", "inpn.mnhn.fr"],
)
return {
"query": name,
"tool": "get_species_information",
"gbif": gbif_info,
"additional_source_search": botanical_search,
}
@mcp.tool()
async def get_care_sources(name: str) -> dict[str, Any]:
"""Return available raw care-source evidence for horticultural topics.
This tool returns source records only. It does not decide final light, watering,
humidity, substrate, fertilisation, temperature or repotting recommendations.
"""
settings = load_settings()
async with BotanicalHTTPClient(timeout=settings.timeout) as http:
firecrawl = FirecrawlClient(http, settings)
targeted = await firecrawl.search(
f"{name} light temperature humidity watering substrate fertilization repotting",
limit=8,
domains=["rhs.org.uk", "edu", "missouribotanicalgarden.org", "powo.science.kew.org"],
)
return {
"query": name,
"tool": "get_care_sources",
"rhs_candidates": rhs_care_source_candidates(name),
"targeted_source_search": targeted,
}
@mcp.tool()
async def get_image_sources(name: str, limit: int = 10) -> dict[str, Any]:
"""Find usable image sources with URL, licence, author and source metadata."""
settings = load_settings()
async with BotanicalHTTPClient(timeout=settings.timeout) as http:
wikimedia = WikimediaClient(http)
images = await wikimedia.image_sources(name, limit=limit)
return {
"query": name,
"tool": "get_image_sources",
"wikimedia_commons": images,
}
@mcp.tool()
async def search_botanical_sources(query: str, limit: int = 5) -> dict[str, Any]:
"""Run a targeted source search over allowed botanical/horticultural sources.
Uses a local Firecrawl instance when FIRECRAWL_API_URL is configured. Otherwise
returns explicit suggested source-constrained searches without scraping.
"""
settings = load_settings()
async with BotanicalHTTPClient(timeout=settings.timeout) as http:
firecrawl = FirecrawlClient(http, settings)
return await firecrawl.search(query, limit=limit)
def main() -> None:
mcp.run()
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from typing import Any
from urllib.parse import quote_plus
from .models import evidence, utc_now_iso
TELA_SOURCE = "Tela Botanica"
def tela_source_candidates(name: str) -> dict[str, Any]:
query = quote_plus(name)
url = f"https://www.tela-botanica.org/?s={query}"
return {
"status": "source_candidates",
"source": TELA_SOURCE,
"retrieved_at": utc_now_iso(),
"records": [
{
"value": evidence(
"Tela Botanica search candidate; useful for Francophone botanical references when available.",
source=TELA_SOURCE,
url=url,
confidence="low",
)
}
],
}
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import Any
from .http import BotanicalHTTPClient
from .models import evidence
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
COMMONS_SOURCE = "Wikimedia Commons"
class WikimediaClient:
def __init__(self, http: BotanicalHTTPClient):
self.http = http
async def image_sources(self, query: str, limit: int = 10) -> list[dict[str, Any]]:
data = await self.http.get_json(
COMMONS_API,
{
"action": "query",
"format": "json",
"generator": "search",
"gsrsearch": query,
"gsrnamespace": 6,
"gsrlimit": min(max(limit, 1), 20),
"prop": "imageinfo",
"iiprop": "url|extmetadata|user",
},
)
pages = data.get("query", {}).get("pages", {})
records: list[dict[str, Any]] = []
for page in pages.values() if isinstance(pages, dict) else []:
imageinfo = (page.get("imageinfo") or [{}])[0]
metadata = imageinfo.get("extmetadata") or {}
image_url = imageinfo.get("url") or imageinfo.get("descriptionurl")
source_url = imageinfo.get("descriptionurl") or f"https://commons.wikimedia.org/wiki/{page.get('title', '').replace(' ', '_')}"
records.append(
{
"title": page.get("title"),
"image_url": evidence(image_url, source=COMMONS_SOURCE, url=source_url, confidence="medium"),
"license": evidence(_meta(metadata, "LicenseShortName") or _meta(metadata, "UsageTerms"), source=COMMONS_SOURCE, url=source_url, confidence="medium"),
"author": evidence(_meta(metadata, "Artist") or imageinfo.get("user"), source=COMMONS_SOURCE, url=source_url, confidence="medium"),
"source": evidence(_meta(metadata, "ObjectName") or page.get("title"), source=COMMONS_SOURCE, url=source_url, confidence="medium"),
"raw": {"pageid": page.get("pageid"), "metadata": metadata},
}
)
return records
def _meta(metadata: dict[str, Any], key: str) -> Any:
value = metadata.get(key)
if isinstance(value, dict):
return value.get("value")
return value