875a190983
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>
77 lines
2.4 KiB
Python
77 lines
2.4 KiB
Python
"""HTTP Basic auth in front of the FastMCP Streamable-HTTP transport.
|
|
|
|
MetaMCP can inject ``Authorization: Basic <b64>`` 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)
|