feat: add OpenHerbarium MCP server core
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
"""OpenHerbarium MCP: sourced botanical knowledge access for MCP clients."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.0"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]]
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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": [],
|
||||
}
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user