Initial commit: ag-bids MCP server
Exposes live + historical ag-bids commodity data (from the ag-monitor service at agbids.paul.farm) as MCP tools, sitting behind MetaMCP at https://mcp.jpaul.io/metamcp/ag-bids/mcp. Pattern mirrors zerto-docs-rag with one addition: HTTP Basic auth in front of the streamable-HTTP transport so namespace guessers can't reach the tools. Stdio transport is unaffected (used by local Claude Desktop dev). Tools (markdown returns, ~15 LOC each): best_local_bid(commodity) — where to sell corn/soy/wheat today, for the current calendar month only current_lime_price() — latest lime quotes ($/ton) current_input_price(product?) — MAP / Potash / Lime latest_prices(...) — filtered snapshot price_history(...) — per-(source,delivery) trend list_sources / list_commodities / list_deliveries source_health() — healthy / stale / down buckets todays_summary() — same shape as morning brief snapshot Data path: ag-bids-mcp -> X-API-Key -> /api/data/* on ag-monitor (reuses BRIEF_API_KEY). Tests: 24 covering the httpx client, markdown formatters, HTTP Basic middleware (401/200), and JSONL usage logging. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
"""Thin HTTPS client for ag-monitor's /api/data/* endpoints.
|
||||
|
||||
The MCP server doesn't talk to SQLite directly — it goes over HTTPS to the
|
||||
ag-monitor service on a different LAN host. One X-API-Key header guards
|
||||
every call. Failures bubble up as ``AgBidsError`` so tool functions can
|
||||
turn them into user-friendly markdown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgBidsError(RuntimeError):
|
||||
"""Anything went wrong calling ag-monitor."""
|
||||
|
||||
|
||||
def _config() -> tuple[str, str, float]:
|
||||
url = os.environ.get("AG_BIDS_API_URL", "https://agbids.paul.farm").rstrip("/")
|
||||
key = os.environ.get("AG_BIDS_API_KEY", "")
|
||||
timeout = float(os.environ.get("AG_BIDS_API_TIMEOUT_SECS", "20"))
|
||||
if not key:
|
||||
raise AgBidsError("AG_BIDS_API_KEY is not set in the environment")
|
||||
return url, key, timeout
|
||||
|
||||
|
||||
def _get(path: str, **params: Any) -> dict | list:
|
||||
url_base, key, timeout = _config()
|
||||
url = f"{url_base}{path}"
|
||||
# Drop None values so the upstream doesn't see empty query strings.
|
||||
clean: dict[str, Any] = {k: v for k, v in params.items() if v is not None and v != ""}
|
||||
try:
|
||||
r = httpx.get(url, params=clean, timeout=timeout,
|
||||
headers={"X-API-Key": key, "Accept": "application/json"})
|
||||
except httpx.RequestError as e:
|
||||
raise AgBidsError(f"GET {url} failed: {e}") from e
|
||||
if r.status_code != 200:
|
||||
raise AgBidsError(
|
||||
f"GET {url} returned {r.status_code}: {r.text[:200]}"
|
||||
)
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError as e:
|
||||
raise AgBidsError(f"GET {url} returned non-JSON: {r.text[:200]}") from e
|
||||
|
||||
|
||||
# ---- Public client API (mirrors /api/data/* shape) ----
|
||||
|
||||
|
||||
def latest(commodity: str | None = None, source: str | None = None,
|
||||
delivery: str | None = None, kind: str | None = None) -> dict:
|
||||
return _get("/api/data/latest",
|
||||
commodity=commodity, source=source, delivery=delivery, kind=kind)
|
||||
|
||||
|
||||
def history(commodity: str, source_id: int | None = None,
|
||||
delivery: str | None = None, days: int = 30) -> dict:
|
||||
return _get("/api/data/history",
|
||||
commodity=commodity, source_id=source_id,
|
||||
delivery=delivery, days=days)
|
||||
|
||||
|
||||
def best(commodity: str) -> dict:
|
||||
return _get("/api/data/best", commodity=commodity)
|
||||
|
||||
|
||||
def inputs(product: str | None = None) -> dict:
|
||||
return _get("/api/data/inputs", product=product)
|
||||
|
||||
|
||||
def sources() -> dict:
|
||||
return _get("/api/data/sources")
|
||||
|
||||
|
||||
def deliveries(commodity: str) -> dict:
|
||||
return _get("/api/data/deliveries", commodity=commodity)
|
||||
|
||||
|
||||
def todays_summary() -> dict:
|
||||
"""Brief snapshot — same blob the morning email digest uses."""
|
||||
return _get("/api/brief/snapshot", kind="morning")
|
||||
Reference in New Issue
Block a user