Files
ag-bids-mcp/ag_bids_mcp/auth.py
T
justin 875a190983 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>
2026-05-20 11:37:46 -04:00

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)