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,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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user