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,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 <b64>`` 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()
|
||||
Reference in New Issue
Block a user