commit 875a190983195137ac9688b597eb9ed12999b3b8 Author: Justin Paul Date: Wed May 20 11:34:38 2026 -0400 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) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a47442c --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# --- MCP transport --- +# stdio for local Claude Desktop dev, streamable-http for the deployed container. +MCP_TRANSPORT=streamable-http +MCP_HOST=0.0.0.0 +MCP_PORT=8000 + +# --- Upstream: ag-monitor (where the data actually lives) --- +# Reuse the same key ag-monitor already issues for /api/brief/* — both clients +# are internal-trusted, so a separate key is unnecessary friction. +AG_BIDS_API_URL=https://agbids.paul.farm +AG_BIDS_API_KEY= +AG_BIDS_API_TIMEOUT_SECS=20 + +# --- HTTP Basic auth (REQUIRED — server refuses to start without both) --- +# These guard the MCP itself. MetaMCP injects them on every upstream call. +AG_BIDS_MCP_USER= +AG_BIDS_MCP_PASS= + +# --- Per-tool-call usage logging --- +USAGE_LOG_DIR=/app/var/logs +USAGE_LOG_KEEP_DAYS=90 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f96bd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +.env.local +__pycache__/ +*.pyc +.venv/ +venv/ +.pytest_cache/ +var/ +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4025198 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY ag_bids_mcp ./ag_bids_mcp + +RUN mkdir -p /app/var/logs + +# Streamable-HTTP transport in container; switch to stdio via env for dev. +ENV MCP_TRANSPORT=streamable-http \ + MCP_HOST=0.0.0.0 \ + MCP_PORT=8000 \ + USAGE_LOG_DIR=/app/var/logs \ + USAGE_LOG_KEEP_DAYS=90 + +EXPOSE 8000 + +CMD ["python", "-m", "ag_bids_mcp.server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba0f56c --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# ag-bids-mcp + +Model Context Protocol server exposing live + historical commodity price data +collected by [ag-monitor](https://git.jpaul.io/justin/ag-bids). Sits behind +the user's MetaMCP gateway at `https://mcp.jpaul.io/metamcp/ag-bids/mcp`. + +## What it does + +Lets an LLM client (Claude Desktop, OpenWebUI, anything that speaks MCP) ask +plain-English questions like: + +- "What's the best place to sell corn today?" +- "What's the current price of lime?" +- "Show me the corn basis trend at Andersons Greenville over the last 30 days." +- "Which sources are stale or failing?" + +## How it talks to data + +All data is read from ag-monitor over HTTPS: + +``` +ag-bids-mcp ── X-API-Key ─► https://agbids.paul.farm/api/data/* +``` + +Endpoints used: `/api/data/latest`, `/history`, `/best`, `/inputs`, `/sources`, +`/deliveries`. See the [ag-monitor source](https://git.jpaul.io/justin/ag-bids) +for the contract. + +## Authentication + +This MCP enforces **HTTP Basic** auth in front of the FastMCP HTTP transport. +Set both: + +``` +AG_BIDS_MCP_USER= +AG_BIDS_MCP_PASS= +``` + +MetaMCP is configured to inject `Authorization: Basic ` on every upstream +call. Direct access without the header returns `401`. + +If either env var is unset the server refuses to start (fail closed). + +## Local dev (stdio) + +```bash +python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # fill in AG_BIDS_API_KEY + AG_BIDS_MCP_USER/PASS +MCP_TRANSPORT=stdio python -m ag_bids_mcp.server +``` + +Wire into Claude Desktop's `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "ag-bids": { + "command": "/path/to/venv/bin/python", + "args": ["-m", "ag_bids_mcp.server"], + "env": { + "MCP_TRANSPORT": "stdio", + "AG_BIDS_API_URL": "https://agbids.paul.farm", + "AG_BIDS_API_KEY": "...", + "AG_BIDS_MCP_USER": "x", + "AG_BIDS_MCP_PASS": "y" + } + } + } +} +``` + +(The Basic auth check is skipped automatically when `MCP_TRANSPORT=stdio` since +stdio has no HTTP layer.) + +## Deploy (MetaMCP host) + +See [deploy/README.md](deploy/README.md). Container image is pulled from +`git.jpaul.io/justin/ag-bids-mcp:latest`; Watchtower on the MetaMCP host +auto-updates it every 5 minutes. + +## Tools exposed + +| Tool | Returns | +|---|---| +| `best_local_bid(commodity)` | Where to sell `corn`, `soy`, or `wheat` for this month's delivery — markdown one-liner + table | +| `current_lime_price()` | Latest lime quotes across all manual-entry sources | +| `current_input_price(product?)` | MAP / Potash / Lime — all three or one | +| `latest_prices(commodity?, source?, delivery?)` | Snapshot table, optionally filtered | +| `price_history(commodity, source?, delivery?, days?)` | Compact time series | +| `list_sources()` | Active scrapers + last-success timestamps | +| `list_commodities()` | corn, soy, wheat, map, potash, lime | +| `list_deliveries(commodity)` | Posted delivery labels, chronological | +| `source_health()` | Stale / failing / healthy summary | +| `todays_summary()` | Same shape as the morning brief snapshot | diff --git a/ag_bids_mcp/__init__.py b/ag_bids_mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ag_bids_mcp/auth.py b/ag_bids_mcp/auth.py new file mode 100644 index 0000000..129585d --- /dev/null +++ b/ag_bids_mcp/auth.py @@ -0,0 +1,76 @@ +"""HTTP Basic auth in front of the FastMCP Streamable-HTTP transport. + +MetaMCP can inject ``Authorization: Basic `` on every upstream call, +so this is the simplest robust gate. Two env vars (``AG_BIDS_MCP_USER`` and +``AG_BIDS_MCP_PASS``) are required at process startup; the server fails +closed if either is missing. + +Stdio transport (local dev with Claude Desktop) skips this entirely — no +HTTP layer exists in stdio mode. +""" + +from __future__ import annotations + +import base64 +import logging +import os +import secrets + +from starlette.requests import Request +from starlette.responses import PlainTextResponse, Response + +log = logging.getLogger(__name__) + +REALM = "ag-bids MCP" + + +def expected_credentials() -> tuple[str, str]: + """Return the (user, pass) the server enforces. Raises if missing.""" + u = os.environ.get("AG_BIDS_MCP_USER", "") + p = os.environ.get("AG_BIDS_MCP_PASS", "") + if not u or not p: + raise RuntimeError( + "AG_BIDS_MCP_USER and AG_BIDS_MCP_PASS must both be set for HTTP " + "Basic auth on the ag-bids MCP server." + ) + return u, p + + +def _decode_basic(header: str) -> tuple[str, str] | None: + if not header or not header.lower().startswith("basic "): + return None + try: + decoded = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") + except (ValueError, UnicodeDecodeError): + return None + user, _, pw = decoded.partition(":") + return user, pw + + +def _check(presented_user: str, presented_pass: str) -> bool: + expected_user, expected_pass = expected_credentials() + return ( + secrets.compare_digest(presented_user, expected_user) + and secrets.compare_digest(presented_pass, expected_pass) + ) + + +def _unauthorized() -> Response: + return PlainTextResponse( + "Unauthorized", + status_code=401, + headers={"WWW-Authenticate": f'Basic realm="{REALM}"'}, + ) + + +async def basic_auth_middleware(request: Request, call_next): + """Starlette middleware that 401s anything missing valid Basic creds.""" + creds = _decode_basic(request.headers.get("authorization", "")) + if creds is None: + log.info("auth: missing/malformed Authorization header (path=%s)", request.url.path) + return _unauthorized() + user, pw = creds + if not _check(user, pw): + log.info("auth: bad credentials (user=%r path=%s)", user, request.url.path) + return _unauthorized() + return await call_next(request) diff --git a/ag_bids_mcp/client.py b/ag_bids_mcp/client.py new file mode 100644 index 0000000..8642343 --- /dev/null +++ b/ag_bids_mcp/client.py @@ -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") diff --git a/ag_bids_mcp/format.py b/ag_bids_mcp/format.py new file mode 100644 index 0000000..16128b7 --- /dev/null +++ b/ag_bids_mcp/format.py @@ -0,0 +1,249 @@ +"""JSON-from-ag-monitor → markdown helpers. + +One function per @mcp.tool. Markdown is what FastMCP tools return; Claude / +OpenWebUI render it nicely. Cents → dollars formatting is centralized here so +all tools use the same precision (4 decimals for $/bu, 2 decimals for $/ton). +""" + +from __future__ import annotations + +from typing import Optional + + +def _bu(cents: Optional[int]) -> str: + if cents is None: + return "—" + return f"${cents / 100:.4f}" + + +def _ton(cents: Optional[int]) -> str: + if cents is None: + return "—" + return f"${cents / 100:.2f}" + + +def _basis(cents: Optional[int]) -> str: + if cents is None: + return "—" + sign = "+" if cents >= 0 else "" + return f"{sign}{cents / 100:.2f}" + + +def _delta_arrow(cents: Optional[int]) -> str: + if cents is None or cents == 0: + return "—" + return "▲" if cents > 0 else "▼" + + +# ---------- best_local_bid ---------- + + +def fmt_best(commodity: str, payload: dict) -> str: + best = payload.get("best") + today = payload.get("today") + if not best: + return ( + f"### Best place to sell {commodity} today ({today})\n\n" + f"No current-month {commodity} bids posted across the tracked sources.\n" + ) + return ( + f"### Best place to sell {commodity} today ({today})\n\n" + f"**{best['source_name']}** — delivery **{best['delivery']}** — " + f"bid **{_bu(best['bid_cents'])}/bu** (basis {_basis(best.get('basis_cents'))}, " + f"futures {best.get('futures_contract') or '—'})\n\n" + f"_Fetched {best.get('fetched_at') or '?'}_\n" + ) + + +# ---------- inputs / fertilizer ---------- + + +def fmt_inputs(payload: dict) -> str: + rows = payload.get("rows") or [] + product = payload.get("product") + title = f"### {product.upper()} prices" if product else "### Fertilizer + lime prices" + if not rows: + scope = product or "any tracked input" + return f"{title}\n\nNo {scope} prices on file.\n" + lines = [ + title, "", + "| Source | Product | Delivery | Price ($/ton) | Fetched |", + "|---|---|---|---:|---|", + ] + for r in rows: + lines.append( + f"| {r['source_name']} | {r.get('display_name') or r['commodity']} | " + f"{r['delivery']} | {_ton(r.get('bid_cents'))} | {r.get('fetched_at') or '?'} |" + ) + return "\n".join(lines) + "\n" + + +# ---------- latest snapshot ---------- + + +def fmt_latest(payload: dict) -> str: + rows = payload.get("rows") or [] + if not rows: + return "### Latest prices\n\nNo rows match those filters.\n" + lines = [ + "### Latest prices", "", + "| Source | Commodity | Delivery | Bid | Basis | Futures | Fetched |", + "|---|---|---|---:|---:|---|---|", + ] + for r in rows: + unit_fmt = _ton if r.get("commodity_kind") == "fertilizer" else _bu + lines.append( + f"| {r['source_name']} | {r.get('display_name') or r['commodity']} | " + f"{r['delivery']} | {unit_fmt(r.get('bid_cents'))} | " + f"{_basis(r.get('basis_cents'))} | {r.get('futures_contract') or '—'} | " + f"{r.get('fetched_at') or '?'} |" + ) + return "\n".join(lines) + "\n" + + +# ---------- price history ---------- + + +def fmt_history(payload: dict, max_rows: int = 60) -> str: + rows = payload.get("rows") or [] + commodity = payload.get("commodity") + days = payload.get("days") + if not rows: + return f"### {commodity} history ({days}d)\n\nNo samples in the window.\n" + + # Per (source, delivery) trend annotation: first vs last sample. + series: dict[tuple, list[dict]] = {} + for r in rows: + series.setdefault((r["source_name"], r["delivery"]), []).append(r) + + lines = [f"### {commodity} price history — last {days} days", ""] + for (src, dlv), pts in sorted(series.items()): + if not pts: + continue + first = pts[0].get("bid_cents") + last = pts[-1].get("bid_cents") + delta = (last - first) if (first is not None and last is not None) else None + arrow = _delta_arrow(delta) + lines.append( + f"- **{src}** / {dlv}: {len(pts)} samples · " + f"{_bu(first)} → {_bu(last)} {arrow} {_basis(delta) if delta is not None else ''}".rstrip() + ) + + # If the history is shallow include the raw rows too (helpful for charts). + if sum(len(p) for p in series.values()) <= max_rows: + lines.extend([ + "", + "| Time | Source | Delivery | Bid | Basis |", + "|---|---|---|---:|---:|", + ]) + for r in rows[-max_rows:]: + lines.append( + f"| {r['fetched_at']} | {r['source_name']} | {r['delivery']} | " + f"{_bu(r.get('bid_cents'))} | {_basis(r.get('basis_cents'))} |" + ) + return "\n".join(lines) + "\n" + + +# ---------- sources / health ---------- + + +def fmt_sources(payload: dict) -> str: + src = payload.get("sources") or [] + if not src: + return "### Sources\n\nNo active sources.\n" + lines = [ + "### Tracked sources", "", + "| Source | Kind | Last success | Consecutive failures | Last error |", + "|---|---|---|---:|---|", + ] + for s in src: + lines.append( + f"| {s['name']} | {s['kind']} | {s.get('last_success_at') or '—'} | " + f"{s.get('consecutive_failures') or 0} | {s.get('last_error') or ''} |" + ) + return "\n".join(lines) + "\n" + + +def fmt_health(payload: dict) -> str: + src = payload.get("sources") or [] + healthy, stale, down = [], [], [] + for s in src: + n = s.get("consecutive_failures") or 0 + if n >= 3: + down.append(s) + elif not s.get("last_success_at"): + stale.append(s) + else: + healthy.append(s) + lines = ["### Source health", ""] + lines.append(f"- ✅ Healthy: **{len(healthy)}**") + lines.append(f"- ⚠ Stale (never succeeded): **{len(stale)}**") + lines.append(f"- ❌ Down (3+ consecutive failures): **{len(down)}**") + if stale or down: + lines.append("") + lines.append("| Source | State | Last success | Failures | Error |") + lines.append("|---|---|---|---:|---|") + for s in stale + down: + state = "stale" if s in stale else "down" + lines.append( + f"| {s['name']} | {state} | {s.get('last_success_at') or '—'} | " + f"{s.get('consecutive_failures') or 0} | " + f"{(s.get('last_error') or '')[:80]} |" + ) + return "\n".join(lines) + "\n" + + +# ---------- list helpers ---------- + + +def fmt_deliveries(payload: dict) -> str: + commodity = payload.get("commodity") + labels = payload.get("deliveries") or [] + if not labels: + return f"### {commodity} deliveries\n\nNo posted deliveries.\n" + return ( + f"### Posted delivery labels for {commodity}\n\n" + + "\n".join(f"- {x}" for x in labels) + + "\n" + ) + + +def fmt_commodities() -> str: + return ( + "### Tracked commodities\n\n" + "- **corn** ($/bu)\n" + "- **soy** — soybeans ($/bu)\n" + "- **wheat** ($/bu) — tracked but excluded from daily brief emails\n" + "- **map** — MAP 11-52-0 ($/ton)\n" + "- **potash** — Potash 0-0-60 ($/ton)\n" + "- **lime** ($/ton)\n" + ) + + +# ---------- today's summary ---------- + + +def fmt_summary(payload: dict) -> str: + today = payload.get("today") + prev = payload.get("prev_trading_day") + lines = [f"### Market summary — today {today} vs prev trading day {prev}", ""] + for c in payload.get("commodities", []): + f = c.get("futures") or {} + chg = f.get("change_cents") + arrow = _delta_arrow(chg) + lines.append( + f"**{c['display_name']}** — CBOT {f.get('contract','?')} " + f"last {_bu(f.get('today_last_cents'))}, prev close " + f"{_bu(f.get('prev_close_cents'))} {arrow} {_basis(chg) if chg is not None else ''}" + ) + bft = c.get("best_for_today") + if bft: + lines.append( + f" · Best for today's delivery ({bft['delivery']}): " + f"**{bft['source_name']}** @ {_bu(bft['bid_cents'])} " + f"(basis {_basis(bft.get('basis_cents'))})" + ) + else: + lines.append(" · No current-month local bid posted.") + lines.append("") + return "\n".join(lines).rstrip() + "\n" diff --git a/ag_bids_mcp/server.py b/ag_bids_mcp/server.py new file mode 100644 index 0000000..d9fda54 --- /dev/null +++ b/ag_bids_mcp/server.py @@ -0,0 +1,275 @@ +"""ag-bids MCP server. + +Mirrors the zerto-docs-rag layout — FastMCP, ``@mcp.tool()`` decorators +returning markdown strings, dual stdio/streamable-http transport — with one +addition: HTTP Basic auth in front of the streamable-http transport. MetaMCP +upstream config injects ``Authorization: Basic `` so the proxied call +sails through; direct hits without the header get 401. + +Run locally (stdio for Claude Desktop): + MCP_TRANSPORT=stdio python -m ag_bids_mcp.server + +Run in a container (HTTP for MetaMCP upstream): + python -m ag_bids_mcp.server # default transport = streamable-http +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +from typing import Annotated + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +from ag_bids_mcp import client, format as fmt +from ag_bids_mcp.auth import basic_auth_middleware, expected_credentials +from ag_bids_mcp.usage import track + +logging.basicConfig( + level=os.environ.get("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(name)s %(message)s", + stream=sys.stderr, +) +log = logging.getLogger("ag-bids-mcp") + + +mcp = FastMCP("ag-bids", stateless_http=True) + + +VALID_GRAIN = {"corn", "soy", "wheat"} +VALID_INPUT = {"lime", "map", "potash"} + + +# ============================================================================ +# Tools +# ============================================================================ + + +@mcp.tool() +def best_local_bid( + commodity: Annotated[ + str, Field(description="Grain to look up: 'corn', 'soy' (soybeans), or 'wheat'.") + ], +) -> str: + """Return the highest local bid for *this calendar month's* delivery for + the given grain. This is the "where should I haul today" answer.""" + commodity = commodity.strip().lower() + with track("best_local_bid", commodity=commodity): + if commodity not in VALID_GRAIN: + return f"`commodity` must be one of: {sorted(VALID_GRAIN)}" + payload = client.best(commodity) + return fmt.fmt_best(commodity, payload) + + +@mcp.tool() +def current_lime_price() -> str: + """Latest lime prices on file across all sources. Lime is rarely posted on + public bid pages — entries usually come from manual admin input.""" + with track("current_lime_price"): + payload = client.inputs(product="lime") + return fmt.fmt_inputs(payload) + + +@mcp.tool() +def current_input_price( + product: Annotated[ + str | None, + Field(description="One of: 'lime', 'map', 'potash'. Omit for all three."), + ] = None, +) -> str: + """Latest fertilizer / lime prices ($/ton).""" + p = product.strip().lower() if product else None + with track("current_input_price", product=p): + if p is not None and p not in VALID_INPUT: + return f"`product` must be one of: {sorted(VALID_INPUT)} (or omit)" + payload = client.inputs(product=p) + return fmt.fmt_inputs(payload) + + +@mcp.tool() +def latest_prices( + commodity: Annotated[ + str | None, + Field(description="Filter to one commodity (corn / soy / wheat / map / potash / lime)."), + ] = None, + source: Annotated[ + str | None, + Field(description="Filter to one source by exact display name."), + ] = None, + delivery: Annotated[ + str | None, + Field(description="Filter to one delivery label (e.g. 'May 2026', 'Oct/Nov 2026')."), + ] = None, +) -> str: + """Snapshot of the latest scraped bid per (source, commodity, delivery).""" + cm = commodity.strip().lower() if commodity else None + with track("latest_prices", commodity=cm, source=source, delivery=delivery): + payload = client.latest(commodity=cm, source=source, delivery=delivery) + return fmt.fmt_latest(payload) + + +@mcp.tool() +def price_history( + commodity: Annotated[ + str, Field(description="One of corn / soy / wheat / map / potash / lime.") + ], + source: Annotated[ + str | None, + Field(description="Optional source display name to narrow the chart."), + ] = None, + delivery: Annotated[ + str | None, + Field(description="Optional delivery label to narrow the chart."), + ] = None, + days: Annotated[ + int, Field(ge=1, le=365, description="Lookback window in days.") + ] = 30, +) -> str: + """Compact price history per (source, delivery) for the chosen commodity. + + Returns per-series ▲/▼ trend annotations plus the raw points if the + window has fewer than ~60 samples.""" + cm = commodity.strip().lower() + with track("price_history", commodity=cm, source=source, delivery=delivery, days=days): + # source_id lookup would require an extra call; for now we accept + # source by name and let users filter the markdown output. The + # underlying /api/data/history accepts source_id; pass-through is + # source-agnostic. + payload = client.history(commodity=cm, delivery=delivery, days=days) + # If user asked for a single source, filter rows post-fetch. + if source: + payload["rows"] = [r for r in payload.get("rows") or [] if r.get("source_name") == source] + return fmt.fmt_history(payload) + + +@mcp.tool() +def list_sources() -> str: + """All active scrapers + their last-success timestamps and any pending failures.""" + with track("list_sources"): + payload = client.sources() + return fmt.fmt_sources(payload) + + +@mcp.tool() +def list_commodities() -> str: + """The complete set of commodities tracked by ag-monitor.""" + with track("list_commodities"): + return fmt.fmt_commodities() + + +@mcp.tool() +def list_deliveries( + commodity: Annotated[ + str, Field(description="Commodity whose posted delivery labels you want.") + ], +) -> str: + """All posted delivery labels for a commodity, sorted chronologically.""" + cm = commodity.strip().lower() + with track("list_deliveries", commodity=cm): + payload = client.deliveries(cm) + return fmt.fmt_deliveries(payload) + + +@mcp.tool() +def source_health() -> str: + """Operational status of every source: healthy, stale, or down.""" + with track("source_health"): + payload = client.sources() + return fmt.fmt_health(payload) + + +@mcp.tool() +def todays_summary() -> str: + """Today's market snapshot — same blob used by the morning email brief. + + Includes CBOT corn + soy continuous futures vs the previous trading + day's close, and the best local bid for each commodity's current-month + delivery.""" + with track("todays_summary"): + payload = client.todays_summary() + return fmt.fmt_summary(payload) + + +# ============================================================================ +# Entry point +# ============================================================================ + + +def _streamable_app_with_auth(): + """Attach Basic-auth middleware directly to FastMCP's Starlette app. + + Earlier we tried mounting the FastMCP app under a parent Starlette app to + install middleware. That broke the streamable-HTTP transport because the + parent's lifespan didn't trigger FastMCP's internal session-manager + task-group startup, so requests hit "Task group is not initialized". + Adding middleware on the FastMCP-provided app keeps the original lifespan + intact. + """ + from starlette.middleware.base import BaseHTTPMiddleware + + app = mcp.streamable_http_app() + app.add_middleware(BaseHTTPMiddleware, dispatch=basic_auth_middleware) + return app + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument( + "--transport", + default=os.environ.get("MCP_TRANSPORT", "stdio"), + choices=["stdio", "streamable-http", "sse"], + ) + p.add_argument("--host", default=os.environ.get("MCP_HOST", "0.0.0.0")) + p.add_argument("--port", type=int, default=int(os.environ.get("MCP_PORT", "8000"))) + args = p.parse_args() + + if args.transport == "stdio": + # stdio has no HTTP layer → no Basic auth to enforce. Useful for + # local Claude Desktop development. + log.info("starting ag-bids MCP on stdio (auth bypassed in stdio mode)") + mcp.run() + return + + # HTTP transports: fail closed if Basic-auth credentials are unset. + expected_credentials() + + # Same DNS-rebinding logic as zerto-docs-rag: behind a Docker DNS name + # like "ag-bids-mcp:8000", FastMCP's default localhost-only check would + # 421 every request. + allowed_hosts = os.environ.get("MCP_ALLOWED_HOSTS") + allowed_origins = os.environ.get("MCP_ALLOWED_ORIGINS") + if ( + os.environ.get("MCP_DISABLE_DNS_REBINDING_PROTECTION") in {"1", "true", "yes"} + or allowed_hosts == "*" + or allowed_origins == "*" + ): + mcp.settings.transport_security.enable_dns_rebinding_protection = False + else: + if allowed_hosts: + mcp.settings.transport_security.allowed_hosts = [ + h.strip() for h in allowed_hosts.split(",") if h.strip() + ] + if allowed_origins: + mcp.settings.transport_security.allowed_origins = [ + o.strip() for o in allowed_origins.split(",") if o.strip() + ] + + if args.transport == "sse": + # SSE transport: same direct-add-middleware pattern. + from starlette.middleware.base import BaseHTTPMiddleware + app = mcp.sse_app() + app.add_middleware(BaseHTTPMiddleware, dispatch=basic_auth_middleware) + else: + app = _streamable_app_with_auth() + + import uvicorn + log.info("starting ag-bids MCP on %s://%s:%s (Basic auth enforced)", + args.transport, args.host, args.port) + uvicorn.run(app, host=args.host, port=args.port, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/ag_bids_mcp/usage.py b/ag_bids_mcp/usage.py new file mode 100644 index 0000000..d9bdd34 --- /dev/null +++ b/ag_bids_mcp/usage.py @@ -0,0 +1,93 @@ +"""Per-tool-call usage logging (one JSONL line per invocation). + +Trimmed from zerto-docs-rag/docs_mcp/usage.py. Captures: timestamp, tool +name, args (commodity / source / delivery / etc — all non-PII), success +flag, error class on failure, elapsed ms. Useful for: spotting hot tools, +seeing which queries fail upstream, weekly summaries. + +Writes to ``$USAGE_LOG_DIR/usage-YYYY-MM-DD.jsonl``, one file per day, +auto-rotated. Old files beyond ``USAGE_LOG_KEEP_DAYS`` are deleted on +each write. +""" + +from __future__ import annotations + +import json +import logging +import os +import time +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +log = logging.getLogger(__name__) + + +def _log_dir() -> Path | None: + raw = os.environ.get("USAGE_LOG_DIR", "") + if not raw: + return None + p = Path(raw) + try: + p.mkdir(parents=True, exist_ok=True) + except OSError as e: + log.warning("usage log dir %s unavailable: %s", raw, e) + return None + return p + + +def _keep_days() -> int: + try: + return int(os.environ.get("USAGE_LOG_KEEP_DAYS", "90")) + except ValueError: + return 90 + + +def _prune(dir_: Path) -> None: + cutoff = (datetime.now(timezone.utc) - timedelta(days=_keep_days())).date() + for f in dir_.glob("usage-*.jsonl"): + try: + date_part = f.stem.removeprefix("usage-") + file_date = datetime.strptime(date_part, "%Y-%m-%d").date() + if file_date < cutoff: + f.unlink() + except (ValueError, OSError): + continue + + +def write(record: dict[str, Any]) -> None: + dir_ = _log_dir() + if dir_ is None: + return + today = datetime.now(timezone.utc).date().isoformat() + path = dir_ / f"usage-{today}.jsonl" + try: + with path.open("a", encoding="utf-8") as fp: + json.dump(record, fp, default=str) + fp.write("\n") + _prune(dir_) + except OSError as e: + log.warning("usage log write failed: %s", e) + + +@contextmanager +def track(tool: str, **fields: Any): + """Wrap a tool body. Logs on both success and failure with elapsed ms.""" + started = time.perf_counter() + record: dict[str, Any] = { + "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "tool": tool, + **fields, + } + try: + yield record + record["ok"] = True + except Exception as e: + record["ok"] = False + record["error_class"] = type(e).__name__ + record["error_msg"] = str(e)[:200] + raise + finally: + record["elapsed_ms"] = int((time.perf_counter() - started) * 1000) + write(record) diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..f051f82 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,101 @@ +# Deploying `ag-bids-mcp` behind MetaMCP + +This runs on the **MetaMCP host (192.168.0.2)** alongside `zerto-docs-mcp`. +It joins the same `mcp` Docker network so MetaMCP can proxy to it by container +DNS name (`http://ag-bids-mcp:8000/mcp`). + +## One-time setup (on 192.168.0.2) + +### 1. Add env vars + +Edit `/home/justin/zerto-docs-rag/deploy/.env`: + +```ini +# Copy this from ag-monitor's .env on 192.168.0.126 (the BRIEF_API_KEY value). +# Same key powers /api/data/* and /api/brief/*. +AG_BIDS_API_KEY= + +# Credentials MetaMCP will send to the MCP via Basic auth. Generate two +# random values — they're not user-facing, only MetaMCP knows them. +AG_BIDS_MCP_USER=agbids-svc +AG_BIDS_MCP_PASS= +``` + +### 2. Paste the service block into compose + +Open `/home/justin/zerto-docs-rag/deploy/docker-compose.yml` and append the +`ag-bids-mcp:` block from [docker-compose.snippet.yml](docker-compose.snippet.yml) +inside `services:`, alongside `zerto-docs-mcp:`. Keep the same indentation +(2 spaces). + +### 3. Build + push the image + +On any dev machine with `docker` and the Gitea registry login: + +```bash +cd ~/github/ag-bids-mcp +docker login git.jpaul.io # use your Gitea PAT +docker build -t git.jpaul.io/justin/ag-bids-mcp:latest . +docker push git.jpaul.io/justin/ag-bids-mcp:latest +``` + +### 4. Start the container + +```bash +cd /home/justin/zerto-docs-rag/deploy +docker compose pull ag-bids-mcp +docker compose up -d ag-bids-mcp +docker compose logs -f ag-bids-mcp # confirm "starting ag-bids MCP on streamable-http://0.0.0.0:8000 (Basic auth enforced)" +``` + +Watchtower (already running on the host) will auto-pull updates every 5 +minutes from this point forward. + +### 5. Register the namespace in MetaMCP + +In the MetaMCP web UI at `https://mcp.jpaul.io`: + +1. **Create namespace** → name: `ag-bids` +2. **Add upstream MCP server** to that namespace: + - **Transport:** Streamable HTTP + - **URL:** `http://ag-bids-mcp:8000/mcp` + - **Authentication:** Basic + - **Username:** matches `AG_BIDS_MCP_USER` + - **Password:** matches `AG_BIDS_MCP_PASS` +3. Save. + +Public endpoint becomes: +**`https://mcp.jpaul.io/metamcp/ag-bids/mcp`** + +### 6. Smoke test from the LAN + +```bash +# 401 without creds +curl -i http://192.168.0.2:8000/mcp 2>&1 | head -3 +# 200 with creds (Initialize handshake will succeed; the MCP doesn't have +# a plain GET, so a curl probe just confirms auth) +curl -i -u "$AG_BIDS_MCP_USER:$AG_BIDS_MCP_PASS" http://192.168.0.2:8000/mcp 2>&1 | head -3 +``` + +Then in a real MCP client (Claude Desktop / OpenWebUI / etc.) configured against +`https://mcp.jpaul.io/metamcp/ag-bids/mcp`, try: + +- **"What's the best place to sell corn today?"** → calls `best_local_bid("corn")` +- **"What's the current price of lime?"** → calls `current_lime_price()` +- **"Are any sources down?"** → calls `source_health()` + +## Rotating credentials + +To rotate the Basic password: change `AG_BIDS_MCP_PASS` in the `.env` on +192.168.0.2 → `docker compose up -d ag-bids-mcp` to restart with the new +value → update the MetaMCP namespace's upstream Basic password to match. + +To rotate the upstream API key: change `BRIEF_API_KEY` in ag-monitor's `.env` +on 192.168.0.126 + restart `api` there, then update `AG_BIDS_API_KEY` on +192.168.0.2 + restart `ag-bids-mcp`. + +## Observability + +- Per-tool-call usage logs: `/home/justin/zerto-docs-rag/deploy/ag-bids-mcp-logs/usage-YYYY-MM-DD.jsonl` +- Container stdout: `docker compose logs ag-bids-mcp` +- Successful auth → no log line; failed auth → INFO line with the offending path diff --git a/deploy/docker-compose.snippet.yml b/deploy/docker-compose.snippet.yml new file mode 100644 index 0000000..35e706f --- /dev/null +++ b/deploy/docker-compose.snippet.yml @@ -0,0 +1,38 @@ +# Paste this service block into /home/justin/zerto-docs-rag/deploy/docker-compose.yml +# on 192.168.0.2 (the MetaMCP host), alongside zerto-docs-mcp. It joins the +# same `mcp` Docker network so MetaMCP can reach it by container DNS name. +# +# Required env vars (set in the same .env that already powers the rest of the +# MetaMCP stack): +# AG_BIDS_API_KEY — copy from ag-monitor's .env (BRIEF_API_KEY) +# AG_BIDS_MCP_USER — username MetaMCP will send in Basic auth +# AG_BIDS_MCP_PASS — password MetaMCP will send in Basic auth + + ag-bids-mcp: + container_name: ag-bids-mcp + image: git.jpaul.io/justin/ag-bids-mcp:latest + pull_policy: always + restart: unless-stopped + environment: + MCP_TRANSPORT: streamable-http + MCP_HOST: 0.0.0.0 + MCP_PORT: "8000" + # Behind a Docker DNS name, FastMCP's localhost-only rebinding-protection + # would 421 every call from MetaMCP. Disable it; the mcp network is private. + MCP_DISABLE_DNS_REBINDING_PROTECTION: "1" + # --- upstream ag-monitor (Cloudflare Tunnel from .0.126) --- + AG_BIDS_API_URL: https://agbids.paul.farm + AG_BIDS_API_KEY: ${AG_BIDS_API_KEY} + AG_BIDS_API_TIMEOUT_SECS: "20" + # --- HTTP Basic auth in front of this MCP --- + AG_BIDS_MCP_USER: ${AG_BIDS_MCP_USER} + AG_BIDS_MCP_PASS: ${AG_BIDS_MCP_PASS} + # --- per-tool-call JSONL usage log --- + USAGE_LOG_DIR: /app/var/logs + USAGE_LOG_KEEP_DAYS: "90" + volumes: + # Survive container recreates (Watchtower rolls this every ~5 min). + - ./ag-bids-mcp-logs:/app/var/logs + networks: [mcp] + labels: + com.centurylinklabs.watchtower.enable: "true" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a90f0fe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +mcp>=1.10 +httpx>=0.27 +pydantic>=2.7 +starlette>=0.39 +uvicorn[standard]>=0.30 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..abf2dba --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,102 @@ +"""HTTP Basic middleware tests.""" + +from __future__ import annotations + +import base64 +import importlib +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + + +def _reload_auth(monkeypatch, user="alice", password="hunter2"): + monkeypatch.setenv("AG_BIDS_MCP_USER", user) + monkeypatch.setenv("AG_BIDS_MCP_PASS", password) + from ag_bids_mcp import auth + importlib.reload(auth) + return auth + + +def _b64(creds: str) -> str: + return base64.b64encode(creds.encode()).decode() + + +def test_expected_credentials_requires_both(monkeypatch): + monkeypatch.delenv("AG_BIDS_MCP_USER", raising=False) + monkeypatch.delenv("AG_BIDS_MCP_PASS", raising=False) + from ag_bids_mcp import auth + importlib.reload(auth) + import pytest + with pytest.raises(RuntimeError): + auth.expected_credentials() + + monkeypatch.setenv("AG_BIDS_MCP_USER", "alice") + monkeypatch.delenv("AG_BIDS_MCP_PASS", raising=False) + importlib.reload(auth) + with pytest.raises(RuntimeError): + auth.expected_credentials() + + +def test_middleware_via_starlette_app(monkeypatch): + """End-to-end: a Starlette app with the middleware returns 401 / 200 correctly.""" + auth = _reload_auth(monkeypatch, "alice", "hunter2") + + from starlette.applications import Starlette + from starlette.middleware import Middleware + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.responses import PlainTextResponse + from starlette.routing import Route + from starlette.testclient import TestClient + + async def hello(request): + return PlainTextResponse("ok") + + app = Starlette( + routes=[Route("/x", endpoint=hello)], + middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth.basic_auth_middleware)], + ) + c = TestClient(app) + + # No header -> 401 + WWW-Authenticate + r = c.get("/x") + assert r.status_code == 401 + assert r.headers.get("www-authenticate", "").startswith("Basic") + + # Wrong creds -> 401 + r = c.get("/x", headers={"Authorization": "Basic " + _b64("alice:wrong")}) + assert r.status_code == 401 + + # Wrong username, right password -> 401 + r = c.get("/x", headers={"Authorization": "Basic " + _b64("eve:hunter2")}) + assert r.status_code == 401 + + # Right creds -> 200 + r = c.get("/x", headers={"Authorization": "Basic " + _b64("alice:hunter2")}) + assert r.status_code == 200 + assert r.text == "ok" + + +def test_malformed_authorization_header(monkeypatch): + auth = _reload_auth(monkeypatch) + from starlette.applications import Starlette + from starlette.middleware import Middleware + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.responses import PlainTextResponse + from starlette.routing import Route + from starlette.testclient import TestClient + + async def hello(request): + return PlainTextResponse("ok") + + app = Starlette( + routes=[Route("/x", endpoint=hello)], + middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth.basic_auth_middleware)], + ) + c = TestClient(app) + # Not even base64 + r = c.get("/x", headers={"Authorization": "Basic !!!not_b64!!!"}) + assert r.status_code == 401 + # Bearer instead of Basic + r = c.get("/x", headers={"Authorization": "Bearer abc"}) + assert r.status_code == 401 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..755dca0 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,137 @@ +"""HTTP client unit tests (no live network).""" + +from __future__ import annotations + +import importlib +import os +import sys + +# Make the package importable when pytest runs from repo root +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + + +def _reload_client(monkeypatch, key="abc", url="http://test.invalid"): + monkeypatch.setenv("AG_BIDS_API_URL", url) + monkeypatch.setenv("AG_BIDS_API_KEY", key) + from ag_bids_mcp import client + importlib.reload(client) + return client + + +def test_missing_api_key_raises(monkeypatch): + monkeypatch.setenv("AG_BIDS_API_URL", "http://test.invalid") + monkeypatch.delenv("AG_BIDS_API_KEY", raising=False) + from ag_bids_mcp import client + importlib.reload(client) + import pytest + with pytest.raises(client.AgBidsError): + client.latest() + + +def test_get_sends_api_key_header(monkeypatch): + client = _reload_client(monkeypatch, key="topsecret") + captured = {} + + class FakeResp: + status_code = 200 + text = "" + def json(self): + return {"count": 0, "rows": []} + + def fake_get(url, params=None, timeout=None, headers=None): + captured["url"] = url + captured["params"] = dict(params or {}) + captured["headers"] = dict(headers or {}) + return FakeResp() + + monkeypatch.setattr(client.httpx, "get", fake_get) + out = client.latest(commodity="corn") + assert captured["headers"]["X-API-Key"] == "topsecret" + assert captured["url"].endswith("/api/data/latest") + assert captured["params"] == {"commodity": "corn"} + assert out == {"count": 0, "rows": []} + + +def test_get_drops_none_params(monkeypatch): + client = _reload_client(monkeypatch) + captured = {} + + class FakeResp: + status_code = 200 + text = "" + def json(self): return {"count": 0, "rows": []} + + def fake_get(url, params=None, timeout=None, headers=None): + captured["params"] = dict(params or {}) + return FakeResp() + + monkeypatch.setattr(client.httpx, "get", fake_get) + client.latest(commodity="corn", source=None, delivery="", kind=None) + # None and "" should both be dropped + assert captured["params"] == {"commodity": "corn"} + + +def test_get_raises_on_non_200(monkeypatch): + client = _reload_client(monkeypatch) + + class FakeResp: + status_code = 401 + text = "no key" + def json(self): return {} + + monkeypatch.setattr(client.httpx, "get", lambda *a, **k: FakeResp()) + import pytest + with pytest.raises(client.AgBidsError): + client.best("corn") + + +def test_get_raises_on_network_error(monkeypatch): + client = _reload_client(monkeypatch) + + def boom(*a, **k): + raise client.httpx.ConnectError("network is unreachable") + + monkeypatch.setattr(client.httpx, "get", boom) + import pytest + with pytest.raises(client.AgBidsError): + client.sources() + + +def test_each_endpoint_hits_expected_path(monkeypatch): + client = _reload_client(monkeypatch) + calls = [] + + class FakeResp: + status_code = 200 + text = "" + def json(self): return {} + + def fake_get(url, params=None, timeout=None, headers=None): + calls.append((url, dict(params or {}))) + return FakeResp() + + monkeypatch.setattr(client.httpx, "get", fake_get) + client.latest() + client.history("corn", days=7) + client.best("soy") + client.inputs(product="lime") + client.sources() + client.deliveries("corn") + client.todays_summary() + + paths = [u.replace("http://test.invalid", "") for u, _ in calls] + assert paths == [ + "/api/data/latest", + "/api/data/history", + "/api/data/best", + "/api/data/inputs", + "/api/data/sources", + "/api/data/deliveries", + "/api/brief/snapshot", + ] + # history call sent commodity + days + assert calls[1][1] == {"commodity": "corn", "days": 7} + # best call sent only commodity + assert calls[2][1] == {"commodity": "soy"} + # todays_summary uses kind=morning + assert calls[6][1] == {"kind": "morning"} diff --git a/tests/test_format.py b/tests/test_format.py new file mode 100644 index 0000000..d12813b --- /dev/null +++ b/tests/test_format.py @@ -0,0 +1,163 @@ +"""Markdown formatter tests — no network, just JSON-in / markdown-out.""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from ag_bids_mcp import format as fmt + + +def test_fmt_best_with_winner(): + payload = { + "commodity": "corn", "today": "2026-05-20", + "best": { + "source_name": "Mercer Landmark — St Henry", + "delivery": "May 2026", + "bid_cents": 491, "basis_cents": 33, + "futures_contract": "ZCK26", + "fetched_at": "2026-05-20T15:00:00+00:00", + }, + } + out = fmt.fmt_best("corn", payload) + assert "Mercer Landmark — St Henry" in out + assert "$4.9100/bu" in out + assert "May 2026" in out + assert "+0.33" in out + + +def test_fmt_best_no_winner(): + payload = {"commodity": "wheat", "today": "2026-05-20", "best": None} + out = fmt.fmt_best("wheat", payload) + assert "No current-month" in out + assert "wheat" in out + + +def test_fmt_inputs_lime_table(): + payload = { + "product": "lime", "count": 2, + "rows": [ + {"source_name": "Bob's Lime Quote", "commodity": "lime", + "display_name": "Lime", "delivery": "spot", + "bid_cents": 42000, "commodity_kind": "fertilizer", + "fetched_at": "2026-05-20T15:00:00+00:00"}, + {"source_name": "Coop X", "commodity": "lime", + "display_name": "Lime", "delivery": "spot", + "bid_cents": 45500, "commodity_kind": "fertilizer", + "fetched_at": "2026-05-20T15:01:00+00:00"}, + ], + } + out = fmt.fmt_inputs(payload) + assert "LIME prices" in out + assert "$420.00" in out + assert "$455.00" in out + assert "Bob's Lime Quote" in out + + +def test_fmt_inputs_empty(): + out = fmt.fmt_inputs({"product": "lime", "rows": []}) + assert "No lime prices on file." in out + + +def test_fmt_latest_table_picks_correct_unit(): + payload = {"count": 2, "rows": [ + {"source_name": "Bambauer", "commodity": "corn", + "display_name": "Corn", "delivery": "May 2026", + "bid_cents": 458, "basis_cents": 2, "commodity_kind": "grain", + "futures_contract": "ZCN26", "fetched_at": "2026-05-20T15:00:00+00:00"}, + {"source_name": "Coop X", "commodity": "lime", + "display_name": "Lime", "delivery": "spot", + "bid_cents": 42000, "basis_cents": None, "commodity_kind": "fertilizer", + "futures_contract": None, "fetched_at": "2026-05-20T15:00:00+00:00"}, + ]} + out = fmt.fmt_latest(payload) + assert "$4.5800" in out # grain in /bu (4 decimals) + assert "$420.00" in out # fertilizer in /ton (2 decimals) + + +def test_fmt_history_with_trend_arrow(): + payload = { + "commodity": "corn", "days": 7, + "rows": [ + {"source_name": "Andersons", "delivery": "May 2026", + "bid_cents": 480, "basis_cents": 25, + "fetched_at": "2026-05-15T15:00:00+00:00"}, + {"source_name": "Andersons", "delivery": "May 2026", + "bid_cents": 496, "basis_cents": 30, + "fetched_at": "2026-05-20T15:00:00+00:00"}, + ], + } + out = fmt.fmt_history(payload) + assert "▲" in out + assert "$4.8000" in out + assert "$4.9600" in out + + +def test_fmt_sources_table(): + payload = {"sources": [ + {"id": 1, "name": "Test Elev", "kind": "elevator", + "last_success_at": "2026-05-20T14:55:00+00:00", + "consecutive_failures": 0, "last_error": None}, + ]} + out = fmt.fmt_sources(payload) + assert "Test Elev" in out + assert "elevator" in out + + +def test_fmt_health_buckets(): + payload = {"sources": [ + {"name": "Healthy", "kind": "elevator", + "last_success_at": "2026-05-20T14:55:00+00:00", + "consecutive_failures": 0, "last_error": None}, + {"name": "Stale", "kind": "elevator", + "last_success_at": None, + "consecutive_failures": 0, "last_error": None}, + {"name": "Down", "kind": "elevator", + "last_success_at": "2026-05-15T14:55:00+00:00", + "consecutive_failures": 5, "last_error": "boom"}, + ]} + out = fmt.fmt_health(payload) + assert "Healthy: **1**" in out + assert "Stale (never succeeded): **1**" in out + assert "Down" in out and "**1**" in out + # Down section lists boom + assert "boom" in out + + +def test_fmt_deliveries(): + out = fmt.fmt_deliveries({"commodity": "corn", + "deliveries": ["May 2026", "Jul 2026"]}) + assert "- May 2026" in out + assert "- Jul 2026" in out + + +def test_fmt_commodities_lists_all_six(): + out = fmt.fmt_commodities() + for name in ("corn", "soy", "wheat", "map", "potash", "lime"): + assert name in out.lower() + + +def test_fmt_summary_includes_best_for_today(): + payload = { + "today": "2026-05-20", "prev_trading_day": "2026-05-19", + "commodities": [ + {"symbol": "corn", "display_name": "Corn", + "futures": {"contract": "ZC=F", "today_last_cents": 458, + "prev_close_cents": 455, "change_cents": 3}, + "best_for_today": { + "source_name": "Mercer Landmark — St Henry", + "delivery": "May 2026", "bid_cents": 491, "basis_cents": 33, + }}, + {"symbol": "soy", "display_name": "Soybeans", + "futures": {"contract": "ZS=F", "today_last_cents": 1180, + "prev_close_cents": 1177, "change_cents": 3}, + "best_for_today": None}, + ], + } + out = fmt.fmt_summary(payload) + assert "Corn" in out and "Soybeans" in out + assert "Mercer Landmark — St Henry" in out + assert "$4.5800" in out # corn last + assert "No current-month local bid posted" in out # soy fallback diff --git a/tests/test_usage.py b/tests/test_usage.py new file mode 100644 index 0000000..0ac97f0 --- /dev/null +++ b/tests/test_usage.py @@ -0,0 +1,77 @@ +"""Usage-log smoke tests — make sure JSONL records land on disk.""" + +from __future__ import annotations + +import importlib +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + + +def test_track_writes_jsonl_record(monkeypatch, tmp_path): + monkeypatch.setenv("USAGE_LOG_DIR", str(tmp_path)) + monkeypatch.setenv("USAGE_LOG_KEEP_DAYS", "90") + from ag_bids_mcp import usage + importlib.reload(usage) + + with usage.track("best_local_bid", commodity="corn"): + pass + + files = list(tmp_path.glob("usage-*.jsonl")) + assert len(files) == 1 + line = files[0].read_text().strip().splitlines()[-1] + rec = json.loads(line) + assert rec["tool"] == "best_local_bid" + assert rec["commodity"] == "corn" + assert rec["ok"] is True + assert "elapsed_ms" in rec + + +def test_track_records_failure(monkeypatch, tmp_path): + monkeypatch.setenv("USAGE_LOG_DIR", str(tmp_path)) + from ag_bids_mcp import usage + importlib.reload(usage) + + import pytest + with pytest.raises(RuntimeError): + with usage.track("price_history", commodity="corn"): + raise RuntimeError("boom") + + files = list(tmp_path.glob("usage-*.jsonl")) + rec = json.loads(files[0].read_text().splitlines()[-1]) + assert rec["ok"] is False + assert rec["error_class"] == "RuntimeError" + assert "boom" in rec["error_msg"] + + +def test_track_no_log_dir_is_silent(monkeypatch): + monkeypatch.delenv("USAGE_LOG_DIR", raising=False) + from ag_bids_mcp import usage + importlib.reload(usage) + + # Should not raise even though no log dir is set + with usage.track("noop"): + pass + + +def test_prune_removes_old_files(monkeypatch, tmp_path): + from datetime import datetime, timedelta, timezone + monkeypatch.setenv("USAGE_LOG_DIR", str(tmp_path)) + monkeypatch.setenv("USAGE_LOG_KEEP_DAYS", "1") + from ag_bids_mcp import usage + importlib.reload(usage) + + # Create a fake old log file + old_date = (datetime.now(timezone.utc) - timedelta(days=5)).date().isoformat() + old_file = tmp_path / f"usage-{old_date}.jsonl" + old_file.write_text('{"old": true}\n') + assert old_file.exists() + + # Writing a new record triggers prune + with usage.track("ping"): + pass + + assert not old_file.exists(), "expected old log file to be pruned"